Arrays

These notes cover arrays.

  1. Static Arrays
  2. Costs-benefit Analysis
    1. Insertion
  3. Dynamic Arrays
  4. Problems
    1. Deduplication
    2. Sequential Search
    3. Binary Search
    4. Maximum Value
    5. Maximum Subarray
    6. Pair Sum
    7. Pair Product
    8. Stock Timing
    9. Intersection
    10. Five Sort

Static Arrays

An array is simply a contiguous block of memory:

Each cell is indexed from 0 to n1,{n-1,} where n{n} is the number of discrete data objects stored; here, n=6.{n=6.} The block of memory can then be treated as an aggregate of scalar data. The size of this block depends on the number of bytes consumed by the scalar data. For example, suppose we declared four arrays, A,{A,} B,{B,} C,{C,} and D:{D:}

char A.length =12char B.length =8Z C.length =6double D.length =5 \eqs{ \df{char} ~ & A.\len={12} \\ \df{char}^* ~ & B.\len={8} \\ \uint ~ & C.\len={6} \\ \df{double} ~ & D.\len={5} }

Each of char,{\df{char},} char,{\df{char}^*,} Z,{\uint,} and double{\df{double}} has a preset size.

arrayelement sizetotal sizestart addressith{i^{\tx{th}}} element
A{A}1B{1\B}12B{12\B}aA{a_A}aA+i{a_A + i}
B{B}8B{8\B}64B{64\B}aB{a_B}aB+8i{a_B + 8i}
C{C}4B{4\B}24B{24\B}aC{a_C}aC+4i{a_C + 4i}
D{D}8B{8\B}40B{40\B}aD{a_D}aD+8i{a_D + 8i}

Initializing an array is akin to claiming property in RAM. That is, nothing else may touch the contiguous block we've said is ours. The right to claim property, however, isn't exclusively ours — other entities on the system bear the right as well. This means that, at any given moment, we have neighbors:

        P 368196Q         \begin{array}{c:c:c:c:c:c:c:c} ~ & ~ & ~ & ~ & ~ & ~ & ~ & ~ \\ \hdashline P ~ & 3 & 6 & 8 & 1 & 9 & 6 & Q \\ \hdashline ~ & ~ & ~ & ~ & ~ & ~ & ~ & ~ \\ \end{array}

Above, P{P} and Q{Q} are other programs running separately from ours. Just as they aren't allowed to touch our block of memory, we aren't allowed to touch theirs.

static array. Let A{A} be a finite sequence of sets [a0,a1,,an1],{\ix{a_0, a_1, \ldots, a_{n-1}},} all initially empty, and let i{i} be an index. We call A{A} a static array if, and only if, the following properties hold. A{A} has a variable length N,{\ell \in \nat,} denoted length A,{\len{A},} corresponding to the cardinality of A.{A.} If i=0,{i = 0,} then A[0]=a0{\rw{A}{0}=a_0} is the first element, and if i=n1,{i = n-1,} then A[n1]=an1{\rw{A}{n-1}=a_{n-1}} is the last element. A{A} has a constant capacity, denoted capacityA,{\capa{A},} which restricts A{A} as follows:

A[k]{B+ωkiff  0i<capacityAundefinedelse \rw{A}{k} \mapsto \begin{cases} \B + {\omega}k & \tx{iff}~~{0 \le i \lt \capa{A}} \\ \df{undefined} & \tx{else} \end{cases}

where BZ+{\B \in \pint} is a constant called the base address, ωZ+{\omega \in \pint} is the datatype size, and kN{k \in \nat} is an index.

Costs-benefit Analysis

Say we have some array A.{A.} To access some element Ai{A_i}, we perform one memory access to read/write the data. That is, a Θ(1){\bigTheta{1}} operation. This isn't always the case for connected data structures like linked lists.

Insertion

One operation where arrays perform poorly, however, is insertion. Suppose we're given the following array, with enough space allocated for an insertion:

We want to insert the value 6 at index 2, currently occupied by 7. We start with a pointer to the last valid index. Then, so long as i{i} is greater than the index we'd like to insert at, we right-shift the elements.



Once i=2,{i=2,} the condition no longer holds. We can now set A[i]=6.{A\ix{i}=6.}

Below is an algorithm for this operation. For all future discussions, we will employ the operation below as the operation A(I,E).{A \prec (I,E).}

array-insert

  • Input: an array A,{A,} an index I,{I,} and an element E.{E.}
  • Output: a mutated A,{A,} wherein A[I]=E.{A\ix{I}=E.}
  • Syntax: A[I]E{A\ix{I} \push E}
  1. init
    1. ilength A{\let{i}{\len{A}}}
  2. do start loop body
    1. A[i]A[i1]{\let{A\ix{i}}{A\ix{i-1}}}
    2. decrement i{\df{decrement}~i}
  3. if i>I{i \gt I} goto(line 1){\goto{1}} end loop body
  4. return A{A}

From the algorithm above, we have the following property:

property. Let A{A} be a static array with length A=n{\len{A}=n} with nN,{n \in \nat,} let i{i} be an index, let E{E} be an object. Given f=A[i]E,{f = A\ix{i} \push E,} if i=n1,{i = n-1,} then fΘ(1);{f \in \bigTheta{1};} otherwise, fΘ(n).{f \in \bigTheta{n}.}

As we'll see later, linked lists handle insertion within Θ(1){\bigTheta{1}} in the worst case. Observe further that we assumed the array has enough memory for us to right-shift. Without such memory, we can assume that the right-most element is effectively lost upon shifting. This reveals both a gift and a curse to arrays: They're space-deterministic. Once declared, we cannot go back to the system and ask, "Actually, could you make that size x{x}?"

Dynamic Arrays

Dynamic arrays are one solution to the static array's problem of space-determinism. Say we start with the following static array A:{A:}

When we first declared A,{A,} it was fixed to a capacity of 3, and we now have just two addresses remaining. The dynamic array's approach is as follows. When we initialize the fourth address, a function allocates a new array B{B} with a capacity of 3+k,{3+k,} writes the contents of A{A} into B,{B,} disclaims A,{A,} and returns the base address of B.{B.} In the diagram below, we assume k=3.{k = 3.}

commandstate
A[3]13{A\ix{3}\gets 13}
define new array B{B} claiming 3 more addresses than A{A}
copy A{A} into B{B}
disclaim A{A}
return B{B}

Problems

Deduplication

Given an array A{A} with integers stored in non-decreasing order — some of which are duplicated, write an algorithm that returns the first n{n} elements of A{A} without duplicates.

Consider the following array A,{A,} whose elements are sorted in non-decreasing order.

We want to remove duplicates in the array (here, 3 and 5). This operation is called deduplicating or deduping A.{A.} Let L{L} be the length of A.{A.} If L<2,{L \lt 2,} then we return A{A} immediately, since we need at least 2 elements for a duplicate. We then set a pointer i{i} on the second element.

Next, we use a variable d{d} to keep track of the last index we saw a duplicate. At every case where A[i]=A[i1]{\rw{A}{i} = \rw{A}{i-1}} (the current element is the same as its predecessor), we increment d.{d.} In doing so, we're effectively marking an index where the duplicated element A[i]{\rw{A}{i}} should go to.

previous stateA[i]=A[i1]{\rw{A}{i} = \rw{A}{i-1}}resulting state
d=0{d=0}
false
d=0{d=0}
d=0{d=0}
false
d=0{d=0}
d=0{d=0}
true
d=1{d=1}
d=1{d=1}
false
d=1{d=1}
d=1{d=1}
false
d=1{d=1}
d=1{d=1}
true
d=2{d=2}

Once the loop is done, we return Ld.{L-d.} Here, we get Ld=5,{L-d=5,} corresponding to the first 5{5} elements of A{A} without duplicates.

dedup

  • Input: An array of non-decreasing integers A,{A,} containing duplicates.
  • Output: The first n{n} elements of A{A} without duplicates.
  1. init
    1. Llength A{\let{L}{\len{A}}}
    2. d0{\let{d}{0}}
    3. i1{\let{i}{1}}
  2. do begin loop body
    1. if A[i]=A[i1]{\rw{A}{i}=\rw{A}{i-1}} then
      1. +(d,1){+(d,1)}
    2. else
      1. A[id]A[i]{\let{\rw{A}{i-d}}{\rw{A}{i}}}
  3. if i<L{i \lt L} goto(line 4){\goto{4}} end loop body
  4. return Ld{L-d}

Write an algorithm that performs sequential search.

Algorithm: Sequential search.

  1. Llength (A){\let{L}{\len(A)}}
  2. i0{\let{i}{0}}
  3. while i<L{i \lt L} do
    1. if v=Ai{v = A_i} return i{i}
    2. i++{i \inc}
  4. return 1{-1}

Write an algorithm that performs binary search.

Algorithm: Binary search.

  1. do
    1. midleft+right2{\let{mid}{\dfrac{left + right}{2}}}
    2. if key=Amid{key = A_{mid}} then return mid{mid}
    3. else if key<Amid{key \lt A_{mid}} return rightmid1{\let{right}{mid-1}}
    4. else leftmid+1{\let{left}{mid + 1}}
  2. return 1{-1}

Maximum Value

Given an array A{A} of integers A,{A,} write a function that returns the largest integer in A{A}.

The standard iterative approach:

find-max

  • Input: a non-empty array of integers A.{A.}
  • Output: The largest integer, max.{max.}
  1. init
    1. maxA[0]{\let{max}{A \ix{0}}}
    2. i1{\let{i}{1}}
    3. Llength A{\let{L}{\len{A}}}
  2. loop a{\df{loop a}} if i<L{i \lt L} then
    1. if A[i]>max{A \ix{i} \gt max} then maxA[i]{\let{max}{A \ix{i}}}
    2. increment i{\df{increment }i}
    3. goto loop a{\df{loop a}}
  3. return max{max}

As seen above, this algorithm has a worst-case time complexity of Θ(n),{\bigTheta{n},} where n{n} is the number of elements. It is, however, memory efficient — Θ(1).{\bigTheta{1}.}

Maximum Subarray

Given an array of integers, find the subarray (containing at least one number) with the largest sum and return its sum. For example, the array [2,1,3,4,1,2,1,5,4]{[-2,1,-3,4,-1,2,1,-5,4]} returns 6,{6,} given that the maximum subarray is [4,1,2,1].{[4,-1,2,1].}

Finding the maximum subarray is a problem with many solutions. One solution is Kadane's algorithm:

kadane's algorithm

  • Input: an array of integers A.{A.}
  • Output: an integer b.{b.}
  1. init
    1. Llength (A){\let{L}{\len{(A)}}}
    2. b,cAi{\let{b,c}{A_i}}
    3. i0{\let{i}{0}}
  2. while i<L{i \lt L} do
    1. cmax(Ai,c+Ai){\let{c}{\max(A_i,c + A_i)}}
    2. bmax(b,c){\let{b}{\max(b,c)}}
  3. return b{b}

This algorithm runs on Θ(n){\bigTheta{n}} time. The variable c{c} is used to keep track of the current running sum, and the variable b{b} is used to keep track of previous running sums (both initially set to the A{A}'s first element). For each array element Ai,{A_i,} we compute c+Ai.{c + A_i.} If c+Ai>0,{c + A_i \gt 0,} we update c{c} to take on the result. Then we compare b{b} against c.{c.} If c{c} (the new running sum) is greater than b{b} (the previous running sum), we update b.{b.} The process continues until we reach the last element. The problem: This algorithm, as written, will only work for positive integers. Below is a trace for [5,4,1,7,8].{[5,4,-1,7,8].}

i{i}Ai{A_i}max(Ai,c+Ai){\max(A_i,c+A_i)}c{c}max(b,c){\max(b,c)}b{b}
14max(4,5+4){\max(4,5+4)}9max(5,9){\max{(5,9)}}9
2-1max(1,9+(1)){\max(-1,9+(-1))}8max(9,8){\max{(9,8)}}9
37max(7,8+7){\max(7,8+7)}15max(9,15){\max{(9,15)}}15
48max(8,15+8){\max(8,15+8)}23max(15,23){\max{(15,23)}}23

Pair Sum

Given an array A{A} and an integer T,{T,} write an algorithm that returns a pair of indices whose elements sum to T.{T.} The indices returned must be unique. For any given A,{A,} such a pair exists.

The approach below uses a hash table, denoted H.{H.} We use a variabe d{d} to keep track of Ta,{T - a,} where a{a} is an element of the array. At each iteration, we store the pair (a,i),{(a,i),} where i{i} is the index of a.{a.} Before we do so, however, we compute d=Ta,{d = T-a,} and check if H{H} contains d.{d.} If it does, then we've found a pair, and return the pair (v,i),{(v,i),} where v{v} is the value associated with the key d.{d.}

Why does this work? Because T=a+d,{T = a + d,} where a{a} and b{b} are elements of the array A.{A.} At each iteration, we see a,{a,} so what we need is a Ta=d,{T - a = d,} where a{a'} (the current element at each iteration) is equal to d.{d.}

pair sum

  • Input:
    • an array of integers A.{A.}
    • an integer T.{T.}
  • Output: a pair (n,m)N×N,{(n,m) \in \nat \times \nat,} corresponding to indices.
  1. init
    1. Llength A{\let{L}{\len{A}}}
    2. Hnew hash-table{\let{H}{\df{new hash-table}}}
    3. i0,{\let{i}{0},} d0{\let{d}{0}}
  2. do start loop body
    1. dTA[i]{\let{d}{T-A\ix{i}}}
    2. if dH{d \in H} then
      1. return (H[d],i){(H \ix{d}, i)}
    3. H(A[i],i){H \push (A \ix{i}, i)}
    4. increment i{\df{increment} ~ i}
  3. if i<L{i \lt L} then goto(line 4){\goto{4}} end loop body
  4. return (  ){(~~)}

This approach has both a linear time complexity and a linear space complexity O(n),{\bigO{n},} hash table implementation aside.

Pair Product

Given an array A{A} and an integer P,{P,} write an algorithm that returns a pair of indices whose elements multiply to the given target. The indices returned must be unique. For any given A,{A,} such a pair exists, and all elements of A{A} are less than P.{P.}

Similar to the pair sum problem, the approach below uses a hash table.

pair product

  • Input: an array of integers A.{A.}
  • Output: an integer P.{P.}
  1. init
    1. Hnew hash-table{\let{H}{\df{new hash-table}}}
    2. Llength A{\let{L}{\len{A}}}
    3. i0,  q{\let{i}{0},~~q}
  2. do begin loop body
    1. if (A[i]0)(T rem A[i]0){(A\ix{i} \neq 0) \land (T \rem A \ix{i} \neq 0)} then goto(line 9){\goto{9}}
    2. qTA[i]{\let{q}{\dfrac{T}{A \ix{i}}}}
    3. if qH{q \in H} then return (H[i],i){(H \ix{i}, i)}
    4. H(A[i],i){H \push (A \ix{i},i)}
    5. increment i{\df{increment} ~ i}
  3. if i<L{i \lt L} then goto(line 4){\goto{4}} end loop body
  4. return {\nil}

This approach takes O(n){\bigO{n}} time, hash table implementation aside, alongside a O(n){\bigO{n}} space complexity.

Stock Timing

Let P{P} be an array of prices, where Pi{P_i} is the price of a given stock on the ith{\ith{i}} day. Write an algorithm below maximizes profit by choosing a single day to buy one stock, and choosing a different day in the future to sell that stock.

Conditions

  • P{\let{P}{}} a non-empty array of prices pi,{p_i,} where pZ+{p \in \ZZ^+}
  1. function  tmaxProfit(P){\fun{maxProfit}{P}}
  2. max profit0{\let{max~profit}{0}}
  3. min purchaseP0{\let{min~purchase}{P_0}}
  4. L{\let{L}{}} length of prices array
  5. i0{\let{i}{0}}
  6. while i<L{i \ltn L} do
    1. if Pi>min purchase{P_i \gtn {min~purchase}} then
      1. profitPimin purchase{\let{profit}{P_i - min~purchase}}
      2. max profitmax(profit,max profit){\let{max~profit}{\max(profit, max~profit)}}
    2. else
      1. min purchasePi{\let{min~purchase}{P_i}}
  7. return max profit{max~profit}

exhibit.

(7,1,5,3,6,4)    5 \ar{7,1,5,3,6,4} \implies 5

On day 2, the price is 1, and on day 5, the price is 6. Thus, the profit is:

61=5 6 - 1 = 5

which is the largest possible profit.

exhibit.

(7,6,4,3,1)    0 \ar{7,6,4,3,1} \implies 0

Here, no transactions are done and the maximum profit is 0.

Intersection

Given in two arrays A{A} and B,{B,} as arguments write an algorithm that returns a new array containing elements common to both arrays (i.e., AB{A \cap B}). The order of insertion is irrelevant, and neither A{A} nor B{B} contain duplicates.

A straightforward approach would be use nested loops, comparing each element of array A{A} against the elements of array B.{B.} The implementation is left as an exercise. Such an approach would yield O(nm),{\bigO{n \by m},} where n{n} is the length of the first array and m{m} is the length of the second. The space complexity would also yield O(min{n,m}){\bigO{\min\set{n,m}}} (the minimum of n{n} and m{m}).

The approach below employs a hash table, keeping the time complexity down to O(n+m),{\bigO{n+m},} where n{n} corresponds to the amount of hash table insertions (assumed to be a constant time operation, barring hash table implementation concerns), and m{m} corresponds to the amount of hash table lookups (again, assumed to be a constant time operation, barring hash table implementation concerns).

intersection

  • Input: an array A{A} and an array B.{B.}
  • Output: an array C,{C,} where C=AB.{C = A \cap B.}
  1. init
    1. LAlength A{\let{L_A}{\len{A}}}
    2. LBlength B{\let{L_B}{\len{B}}}
    3. Hnew hash-table{\let{H}{\df{new hash-table}}}
    4. Cnew array{\let{C}{\df{new array}}}
    5. i0{\let{i}{0}}
  2. do begin loop 1 body
    1. H(A[i],i){H \push (A\ix{i},i)}
    2. increment i{\df{increment}~i}
  3. if i<LA{i \lt L_A} then goto(line 6){\goto{6}} end loop 1 body
  4. i0{\let{i}{0}}
  5. do begin loop 2 body
    1. if B[i]H{B \ix{i} \in H} then
      1. CB[i]{C \push B \ix{i}}
    2. increment i{\df{increment}~i}
  6. if i<LB{i \lt L_B} then goto(line 11){\goto{11}} end loop 2 body
  7. return C.{C.}

While this approach has a linear time complexity, it comes at the cost of an increased space complexity. With the straightforward approach, the space complexity was kept to O(min{n,m}).{\bigO{\min\set{n,m}}.} Under the hash table approach, we now have O(min{n,m})+O(min{n,m}).{\bigO{\min\set{n,m}} + \bigO{\min\set{n,m}}.} The constant multiple can be significant at higher scales. Comparing the lengths of the two arrays to determine which array to push hash table elements to can help reduce the space consumed by the hash table H.{H.}

Five Sort

Given an array integers A{A}, write an algorithm that rearranges the elements of A{A} such that all instances of the integer 5 appear at the end of A.{A.} The algorithm should perform this operation in-place: Mutate the original array and return the mutated array upon completion. Elements that are not 5 may appear in any order in the output, so long as all 5s are at A{A}'s end.

The trick here is to use a two-pointer approach. We start with a pointer i{i} at the start, and a pointer j{j} at the end:

First, we check if j{j} is already pointing at a 5 (j5{j \to 5}). In this case, j5.{j \to 5.} So, we decrement j{j} (and continue decrementing) until j{j} no longer points at a 5 (j↛5{j \not \to 5}).

The i{i} will move (and continue to move) until i5.{i \to 5.}

Once we reach the state where i5{i \to 5} and j↛5,{j \not\to 5,} we have the two pointers swap their pointees:

After the swap is done, we decrement j{j} and increment i.{i.}

Now have i↛5{i \not\to 5} and j↛5,{j \not\to 5,} so we swap.

Increment and decrement again:

Now we have both pointers at the same index, so we end.

five sort

  • Input: an array A.{A.}
  • Output: A{A} mutated, such that, for all indices n,mN,{n,m \in \nat,} if A[n]=5{A \ix{n}=5} and A[m]5,{A \ix{m}\neq 5,} then m<n.{m \lt n.}
  1. init.
    1. i0{\let{i}{0}}
    2. jlength A{\let{j}{\len {A}}}
  2. do start loop body
    1. if A[i]=5{A\ix{i} = 5} then decrement j{\df{decrement}~j}
    2. else if A[i]=5{A \ix{i} = 5} then
      1. A[i]A[j]{A \ix{i} \swap A \ix{j}} swap
      2. increment i{\df{increment}~i}
    3. else increment i{\df{increment}~i}
  3. if i<j{i \lt j} then goto(line 3){\goto{3}} end loop body
  4. return A{A}

The notion of "continue to move" is encapsulated in the strict branching above. That is, at each iteration, we have three mutually exclusive decisions: decrement j,{j,} swap and increment i,{i,} or just increment i.{i.} Notice the effect of this: We don't reach the state i<j{i \lt j} unless we've continued to move.

This algorithm has a linear time complexity, O(n),{\bigO{n},} and a constant space complexity, O(1).{\bigO{1}.}