Programming with mutable vectors is hardly ever needed in the kinds of
programs that we encountered. Still, because it is far more prevalent in
conventional languages,
it is an important skill and deserves more practice
than section
suggests. This section covers
sorting with vectors, but its goal is to practice reasoning about intervals
when processing vectors.
We encountered the idea of sorting as early as section
,
where we designed the sort function. It consumes a list of numbers
and produces a list of numbers with the same items in sorted (ascending or
descending) order. An analogous function for vectors consumes a vector and
produces a new vector. But, using vector mutation, we can also design a
function that changes the vector so that it contains the same items as
before, in a sorted order. Such a function is called an IN-PLACE SORT
because it leaves all the items inside the existing vector.
An in-place-sort function relies exclusively on effects on its input vector to accomplish its task:
;; in-place-sort : (vectorof number) -> void ;; effect: to modify V such that it contains the same items ;; as before but in ascending order (define (in-place-sort V) ...)Examples must demonstrate the effect:
(local ((define v1 (vector 7 3 0 4 1)))
(begin
(in-place-sort v1)
(equal? v1 (vector 0 1 3 4 7))))
Of course, given that in-place-sort consumes a vector, the true
problem is to design the auxiliary function that works on specific
segments of the vector.
The standard template for a vector-processing function uses an auxiliary function:
(define (in-place-sort V)
(local ((define (sort-aux V i)
(cond
[(zero? i) ...]
[else
... (vector-ref V (sub1 i)) ...
... (sort-aux V (sub1 i)) ...])))
(sort-aux V (vector-length V))))
Following the design ideas of intermezzo 5, the auxiliary function consumes
a natural number and uses it as an index into the vector. Because the
initial argument is (vector-length V), the accessible index is
always (sub1 i).
Recall that the key to designing functions such as sort-aux is to formulate a rigorous purpose and/or effect statement. The statement must clarify on which interval of the possible vector indices the function works and what exactly it accomplishes. One natural effect statement follows:
;; sort-aux : (vectorof number) N -> void ;; effect: to sort the interval [0,i) of V in place (define (sort-aux V i) ...)To understand this effect statement in the larger context, let's adapt our original example:
(local ((define v1 (vector 7 3 0 4 1)))
(begin
(sort-aux v1 5)
(equal? v1 (vector 0 1 3 4 7))))
If sort-aux is applied to a vector's length, it should sort the entire
vector. This statement implies that if the first argument is less than the
vector's length only some initial segment of the vector is sorted:
(local ((define v1 (vector 7 3 0 4 1)))
(begin
(sort-aux v1 4)
(equal? v1 (vector 0 3 4 7 1))))
In this particular example, the last number remains in its original
place, and only the first four vector items are sorted.
Now we can analyze each case in the template of sort-aux:
(vector-ref V (sub1 i)) ;and (sort-aux V (sub1 i))The first reminds us that we can use the i-1-st field of V; the second one reminds us of the natural recursion. In this case, the natural recursion sorts the interval [0,(sub1 i)). To finish the task, we must insert the value of the i-1-st field into its proper place in the interval [0,i).
The above examples make this case concrete. When we evaluate (sort-aux v1 4), the number in the last field of v1 remains at its place. The first four items in the vectors are: 0, 3, 4, and 7. To sort the entire interval [0,5), we must insert 1, which is (vector-ref V (sub1 5)), between 0 and 3.
Figure
gathers what we have discussed about
in-place-sort so far. It also includes a specification of
insert, the second auxiliary function. To understand its effect
statement, we reformulate the example for the second clause of
sort-aux:
(local ((define v1 (vector 0 3 4 7 1)))
(begin
(insert 4 v1)
(equal? v1 (vector 0 1 3 4 7))))
In this case, insert moves 1 over three numbers: first
7, then 4, and finally 3. It stops when the next
number in the leftwards direction, that is, 0, is smaller than the
number that is being inserted.
Let's look at a second example for insert:
(local ((define v1 (vector 7 3 0 4 1)))
(begin
(insert 1 v1)
(equal? v1 (vector 3 7 0 4 1))))
Here the problem is to insert 3 into a segment that contains only
one number: 7. This means that insert must swap the values in the
first two fields and must stop then, because 3 can't move any
further to the left.
Now take a look at the template for insert:
(define (insert i V)
(cond
[(zero? i) ...]
[else
... (vector-ref V (sub1 i)) ...
... (insert (sub1 i) V) ... ]))
It is the standard template for a vector-processing auxiliary function. As
usual we distinguish two cases:
The cond-expression that employs the necessary conditions is
(cond [(> (vector-ref V (sub1 i)) (vector-ref V i)) ...] [(<= (vector-ref V (sub1 i)) (vector-ref V i)) (void)])The second clause contains (void) because there is nothing left to do. In the first clause, insert must--at a minimum--swap the values in the two fields. That is, insert must place (vector-ref V i) into field (sub1 i) and (vector-ref V (sub1 i)) into field i. But even that may not be enough. After all, the value in the i-th field may have to wander over several fields as the first example demonstrated. Fortunately, we can easily solve this problem with the natural recursion, which inserts the (vector-ref V (sub1 i)) into its proper place in [0,(sub1 i)] after the swapping has taken place.
Exercise 43.1.1
Test the auxiliary functions for in-place-sort from
figures
and
.
Formulate the tests as boolean-valued expressions.
Develop and test more examples for in-place-sort.
Integrate the pieces. Test the integrated function. Eliminate superflous arguments from the auxiliary programs in the integrated definition, step by step, testing the complete function after each step. Finally, change in-place-sort so that its result is the modified vector. Solution
![]()
Figure: Inserting an item into a sorted vector segment
Exercise 43.1.2
The insert function of figure
performs two
vector mutations for each time the function recurs. Each of these mutations
pushes (vector-ref V i), for the original value of i, to the
left in V until its proper place is found.
Figure
illustrates a slightly better solution. The
situation in the top row assumes that the values a, b,
and c are properly arranged, that is,
(< a b ... c)holds. Furthermore, d is to be inserted and its place is between a and b, that is,
(< a d b)holds, too. The solution is to compare d with all the items in k+1 through i and to shift the items to the right if they are larger than d. Eventually, we find a (or the left end of the vector) and have a ``hole'' in the vector, where d must be inserted. (The hole actually contains b.) This situation is illustrated in the middle row. The last one shows how d is placed between a and b.
Develop a function insert that implements its desired effect according to this description. Hint: The new function must consume d as an additional argument. Solution
Exercise 43.1.3
For many other programs, we could swap the order of the subexpressions in begin-expressions and still get a working program. Let's consider this idea for sort-aux:
;; sort2-aux : (vectorof number) N -> void
(define (sort2-aux V i)
(cond
[(zero? i) (void)]
[else (begin
(insert2 (sub1 i) V)
(sort2-aux V (sub1 i)))]))
The order implies that sort2-aux first inserts the item from
(sub1 i) into some already sorted part of V and then
sorts the remainder of V. Here is a picture that illustrates the
situation graphically:
i-1 a left right
The depicted vector consists of three pieces: a, the item in field (sub1 i), the left fragment, and the right fragment. The questions are which of the two fragments is sorted and into which fragment a should be inserted.
Considering that sort2-aux decreases its first argument and thus sweeps over the vector from right to left, the answers are that the right fragment is initially empty and thus sorted in ascending order by default; the left fragment is still unordered; and a must be inserted into its proper place in the right fragment.
Develop a precise effect statement for sort-aux based on these
observations. Then develop the function insert2 so that
sort2-aux sorts vectors properly. Solution
In section
, we got to know qsort, a function
based on generative recursion. Given a list, qsort constructs
a sorted version in three steps:
Figure
illustrates how this idea can be adapted for an
in-place version that works on vectors. At each stage, the algorithm works
on a specific fragment of the vector. It picks the first item as the
pivot item and rearranges the fragment so that all items smaller
than the pivot appear to the left of pivot and all items larger
than pivot appear to its right. Then qsort is used
twice: once for the fragment between left1 and right1 and
again for the fragment between left2 and right2. Because
each of these two intervals is shorter than the originally given interval,
qsort eventually encounters the empty interval and
stops. After qsort has sorted each fragment, there is nothing
left to do; the partitioning process has arranged the vector into fragments of
ascending order.
Here is the definition of qsort, an in-place sorting algorithm for vectors:
;; qsort : (vectorof number) -> (vectorof number) ;; effect: to modify V such that it contains the same items as before, ;; in ascending order (define (qsort V) (qsort-aux V 0 (sub1 (vector-length V))))The main function's input is a vector, so it uses an auxiliary function to do its job. As suggested above, the auxiliary function consumes the vector and two boundaries. Each boundary is an index into the vector. Initially, the boundaries are 0 and (sub1 (vector-length V)), which means that qsort-aux is to sort the entire vector.;; qsort-aux : (vectorof number) N N -> (vectorof number) ;; effect: sort the interval [left,right] of vector V ;; generative recursion (define (qsort-aux V left right) (cond [(>= left right) V] [else (local ((define new-pivot-position (partition V left right))) (begin (qsort-aux V left (sub1 new-pivot-position)) (qsort-aux V (add1 new-pivot-position) right)))]))
The definition of qsort-aux closely follows the algoritm's description. If left and right describe a boundary of size 1 or less, its task is done. Otherwise, it partitions the vector. Because the partitioning step is a separate complex process, it requires a separate function. It must have both an effect and a result proper, the new index for the pivot item, which is now at its proper place. Given this index, qsort-aux continues to sort V on the intervals [left,(sub1 new-pivot-position)] and [(add1 new-pivot-position), right]. Both intervals are at least one item shorter than the original, which is the termination argument for qsort-aux.
Naturally, the key problem here is the partitioning step, which is implemented by partition:
;; partition : (vectorof number) N N -> N ;; to determine the proper position p of the pivot-item ;; effect: rearrange the vector V so that ;; - all items in V in [left,p) are smaller than the pivot item ;; - all items of V in (p,right] are larger than the pivot item (define (partition V left right) ...)For simplicity, we choose the left-most item in the given interval as the pivot item. The question is how partition can accomplish its task, for example, whether it is a function based on structural recursion or whether it is based on generative recursion. Furthermore, if it is based on generative recursion, the question is what the generative step accomplishes.
finding the swapping points for partition:
left right p sm-1 la-1 sm-2 sm-3 la-2 new-left new-right
swapping the items and recuring on a new interval:
left right p sm-1 sm-3 sm-2 la-1 la-2
stopping the generative recursion and clean-up:
left right p sm-1 sm-3 sm-2 la-1 la-2 new-right new-left Figure: The partitioning process for in-place quick-sort
The best strategy is to consider an example and to see how the partitioning step could be accomplished. The first example is a small vector with six numbers:
(vector 1.1 0.75 1.9 0.35 0.58 2.2)The pivot's position is 0; the pivot item is 1.1. The boundaries are 0 and 5. One item, 1.9, is obviously out of place. If we swap it with 0.58, then the vector is almost perfectly partitioned:
(vector 1.1 0.75 0.58 0.35 1.9 2.2)In this modified vector, the only item out of place is the pivot item itself.
Figure
illustrates the swapping process that we just
described. First, we must find two items to swap. To do that, we search
V for the first item to the right of left that is larger than
the pivot item. Analogously, we search V for the first item to the
left of right that is smaller than the pivot item. These searches
yield two indices: new-left and new-right. Second, we swap
the items in fields new-left and new-right. The result is
that the item at new-left is now smaller than the pivot item and the
one at new-right is larger. Finally, we can continue the swapping
process with the new, smaller interval. When the first step yields values for
new-left and new-right that are out of order, as in the
bottom row of figure
, then we have a mostly partitioned
vector (fragment).
Working through this first example suggests that partition is an algorithm, that is, a function based on generative recursion. Following our recipe, we must ask and answer four questions:
Let's study question 2 with some examples. We stopped working on the first example when the vector had been changed to
(vector 1.1 0.75 0.58 0.35 1.9 2.2)and the interval had been narrowed down to [2,4]. The search for new-left and new-right now yields 4 and 3, respectively. That is,
(<= new-right new-left)holds. Switching the item in field new-right with the original left-most boundary places the pivot item in the proper spot:
(vector 0.35 0.75 0.58 1.1 1.9 2.2)because new-right points to the right-most item in the vector that is smaller than the pivot item.
Before we accept this seemingly simple solution, let's check it with some additional examples, especially vector fragments where the pivot item is the largest or smallest item. Here is one such example:
(vector 1.1 0.1 0.5 0.4)Assuming the initial interval is [0,3], the pivot item is 1.1. Thus, all other items in the vector are smaller than the pivot item, which means that it should end up in the right-most position.
Our process clearly yields 3 for new-right. After all, 0.4 is smaller than pivot. The search for new-left, though, works differently. Since none of the items in the vector is larger than the pivot item, it eventually generates 3 as an index, which is the largest legal index for this vector. At this point the search must stop. Fortunately, new-left and new-right are equal at this point, which implies that the partitioning process can stop and means that we can still swap the pivot item with the one in field new-right. If we do that, we get a perfectly well-partitioned vector:
(vector 0.4 0.1 0.5 0.4 1.1)
The third sample vector's items are all larger than the pivot item:
(vector 1.1 1.2 3.3 2.4)In this case, the search for new-left and new-right must discover that the pivot item is already in the proper spot. And indeed, it does. The search for new-left ends at field 1, which is the first field that contains an item larger than the pivot item. The search for new-right ends with 0, because it is the smallest legal index and the search must stop there. As a result, new-right once again points to that field in the vector that must contain the pivot item for the vector (fragment) to be properly partitioned.
In short, the examples suggest several things:
We can now gradually translate our discussion into Scheme. First, the partitioning process is a function of not just the vector and some interval, but also of the original left-most position of the vector and its content. This suggests the use of locally defined functions and variables:
(define (partition V left right)
(local ((define pivot-position left)
(define the-pivot (vector-ref V left))
(define (partition-aux left right)
...))
(partition-aux left right)))
The alternative is to use an auxiliary function that consumes the pivot's
original position in addition to the vector and the current interval.
Second, the auxiliary function consumes an interval's boundaries. It immediately generates a new pair of indices from these boundaries: new-left and new-right. As mentioned, the searches for the two new boundaries are complex tasks and deserve their own functions:
;; find-new-right : (vectorof number) number N N [>= left] -> N ;; to determine an index i between right and left (inclusive) ;; such that (< (vector-ref V i) the-pivot) holds (define (find-new-right V the-pivot right left) ...)Using these two functions, partition-aux can generate the new boundaries:;; find-new-left : (vectorof number) number N N [<= right] -> N ;; to determine an index i between left and right (inclusive) ;; such that (> (vector-ref V i) the-pivot) holds (define (find-new-left V the-pivot left right) ...)
(define (partition V left right)
(local ((define pivot-position left)
(define the-pivot (vector-ref V left))
(define (partition-aux left right)
(local ((define new-right (find-new-right V the-pivot right left))
(define new-left (find-new-left V the-pivot left right)))
... )))
(partition left right)))
From here the rest of the definition is a plain transliteration of our
discussion into Scheme.
;; partition : (vectorof number) N N -> N ;; to determine the proper position p of the pivot-item ;; effect: rearrange the vector V so that ;; - all items in V in [left,p) are smaller than the pivot item ;; - all items of V in (p,right] are larger than the pivot item ;; generative recursion (define (partition V left right) (local ((define pivot-position left) (define the-pivot (vector-ref V left)) (define (partition-aux left right) (local ((define new-right (find-new-right V the-pivot right left)) (define new-left (find-new-left V the-pivot left right))) (cond [(>= new-left new-right) (begin (swap V pivot-position new-right) new-right)] [else ; (< new-left new-right) (begin (swap V new-left new-right) (partition-aux new-left new-right))])))) (partition left right)));; find-new-right : (vectorof number) number N N [>= left] -> N ;; to determine an index i between right and left (inclusive) ;; such that (< (vector-ref V i) the-pivot) holds ;; structural recursion: see text (define (find-new-right V the-pivot left right) (cond [(= right left) right] [else (cond [(< (vector-ref V right) the-pivot) right] [else (find-new-right V the-pivot left (sub1 right))])]))
Figure
contains the complete definition of
partition, partition-aux, and find-new-right; the
function swap is defined in figure
. The
definition of the search function uses an unusual structural recursion based on
subclasses of natural numbers whose limits are parameters of the
function. Because the search functions are based on a rarely used design
recipe, it is best to design them separately. Still, they are useful only in
the context of partition, which means that they should be integrated
into its definition when their design is completed.
Exercise 43.1.4
Complete the definition of find-new-left. The two definitions have the same structure; develop the common abstraction.
Use the definitions of find-new-right and find-new-left to provide a termination argument for partition-aux.
Use the examples to develop tests for partition. Recall that the function computes the proper place for the pivot item and rearranges a fragment of the vector. Formulate the tests as boolean-valued expressions.
When the functions are properly tested, integrate find-new-right and find-new-left into partition and eliminate superfluous parameters.
Finally, test qsort and produce a single function definition for the in-place quick-sort algorithm. Solution
Exercise 43.1.5
Develop the function vector-reverse!. It inverts the contents of a vector; its result is the modified vector.
Hint: Swap items from both ends until there are no more items to swap. Solution
Exercise 43.1.6
Economists, meteorologists, and many others consistently measure various things and obtain time series. All of them need to understand the idea of ``n-item averages'' or ``smoothing.'' Suppose we have weekly prices for some basket of groceries:
Computing the corresponding three-item average time series proceeds as follows:
There are no averages for the end points, which means a series with k items turns into k-2 averages.
Develop the function list-3-average, which computes the 3-item sliding averages of a list of numbers. That is, we represent a series of grocery prices with lists, and list-3-averages consumes a list such as
(list 1.10 1.12 1.08 1.09 1.11)and produces
(list 1.10 329/300 328/300)in return.
Develop the function vector-3-averages, which computes the 3-item sliding averages of a vector of numbers. Since vectors are mutable, this gives us the alternative of producing a new vector or mutating the existing one.
Develop both versions of the function: one that produces a new vector and another one that mutates the vector it is handed.
Warning: This is a difficult exercise. Compare all three versions and the complexity of designing them. Solution
Exercise 43.1.7
All the examples in this section deal with vector fragments, that is, intervals of natural numbers. Processing an interval requires a starting point for an interval, an end point, and, as the definitions of find-new-right and find-new-left show, a direction of traversal. In addition, processing means applying some function to each point in the interval.
Here is a function for processing intervals:
;; for-interval : N (N -> N) (N -> N) (N -> X) -> X
;; to evaluate (action i (vector-ref V i)) for i, (step i), ...
;; until (end? i) holds (inclusive)
;; generative recursion: step generates new value, end? detects end
;; termination is not guaranteed
(define (for-interval i end? step action)
(begin
(action i)
(cond
[(end? i) (action i)]
[else (for-interval (step i) end? step action)])))
It consumes a starting index, called i, a function for determining
whether the end of the interval has been reached, a function that generates
the next index, and a function that is applied to each point in between.
Assuming (end? (step (step ... (step i) ...))) holds,
for-interval satisfies the following equation:
(for-interval i end? step action)
= (begin (action i)
(action (step i))
...
(action (step (step ... (step i) ...))))
Compare the function definition and the equation with those for
map.
With for-interval we can develop (some) functions on vectors without the traditional detour through an auxiliary function. Instead, we use for-interval the way we used map for processing each item on a list. Here is a function that adds 1 to each vector field:
;; increment-vec-rl : (vector number) -> void
;; effect: to increment each item in V by 1
(define (increment-vec-rl V)
(for-interval (sub1 (vector-length V)) zero? sub1
(lambda (i)
(vector-set! V i (+ (vector-ref V i) 1)))))
It processes the interval [0,(sub1 (vector-length V))],
where the left boundary is determined by zero?, the termination
test. The starting point, however, is (sub1 (vector-length V)),
which is the right-most legal vector index. The third argument to
for-interval, sub1, determines the traversal direction,
which is from right to left, until the index is 0. Finally, the
action is to mutate the contents of the i-th field by adding
1.
Here is a function with the same visible effect on vectors but a different processing order:
;; increment-vec-lr : (vector number) -> void
;; effect: to increment each item in V by 1
(define (increment-vec-lr V)
(for-interval 0 (lambda (i) (= (sub1 (vector-length V)) i)) add1
(lambda (i)
(vector-set! V i (+ (vector-ref V i) 1)))))
Its starting point is 0 and the end point is the right-most legal
index of V. The add1 function determines that the vector
is processed from left to right.
Develop the following functions, using for-interval:
Which of these functions can be defined in terms of vec-for-all
from exercise
?
Looping Constructs: Many programming languages
(must) provide
functions like for-interval as built-in constructs, and force
programmers to use them for processing vectors. As a result, many more
programs than necessary use set! and require complex temporal
reasoning. Solution