Next, we present another data structure, the
that
achieves the same performance bounds as an
by using
two
s. Although the asymptotic performance of the
is no better than that of the
, it is
still worth studying since it offers a good example of how to make a
sophisticated data structure by combining two simpler data structures.
A
represents a list using two
s. Recall that
an
is fast when the operations on it modify elements near
the end. A
places two
s, called
and
, back-to-back so that operations are fast at either end.
ArrayStack<T> front; ArrayStack<T> back;
A
does not explicitly store the number,
,
of elements it contains. It doesn't need to, since it contains
elements. Nevertheless, when
analyzing the
we will still use
to denote the number
of elements it contains.
int size() {
return front.size() + back.size();
}
The
contains list elements with indices
, but stores them in reverse order.
The
contains list elements with indices
in the normal order. In this way,
and
translate into appropriate calls to
or
on either
or
, which take
time per operation.
T get(int i) {
if (i < front.size()) {
return front.get(front.size() - i - 1);
} else {
return back.get(i - front.size());
}
}
T set(int i, T x) {
if (i < front.size()) {
return front.set(front.size() - i - 1, x);
} else {
return back.set(i - front.size(), x);
}
}
Note that, if an index
, then it corresponds to the
element of
at position
, since the
elements of
are stored in reverse order.
Adding and removing elements from a
is illustrated in
Figure 2.4. The
operation manipulates either
or
, as appropriate:
|
void add(int i, T x) {
if (i < front.size()) {
front.add(front.size() - i, x);
} else {
back.add(i - front.size(), x);
}
balance();
}
The
method performs rebalancing of the two
s
and
, by calling the
method. The
implementation of
is described below, but for now it is
sufficient to know that
ensures that, unless
,
and
do not differ by more than a factor
of 3. In particular,
and
.
Next we analyze the cost of
, ignoring the cost of the
operation. If
, then
becomes
.
Since
is an
, the cost of this is
Notice that the first case (2.1) occurs when
.
The second case (2.2) occurs when
. When
, we can't be sure whether the operation affects
or
, but in either case, the operation takes
time, since
and
. Summarizing the situation, we have
The
operation, and its analysis, is similar to the
operation.
T remove(int i) {
T x;
if (i < front.size()) {
x = front.remove(front.size()-i-1);
} else {
x = back.remove(i-front.size());
}
balance();
return x;
}
Finally, we study the
operation performed by
and
. This operation is used to ensure that neither
nor
gets too big (or too small). It ensures that, unless there
are fewer than 2 elements, each of
and
contain at least
elements. If this is not the case, then it moves elements between
them so that
and
contain exactly
elements and
elements, respectively.
void balance() {
if (3*front.size() < back.size()
|| 3*back.size() < front.size()) {
int n = front.size() + back.size();
int nf = n/2;
array<T> af(max(2*nf, 1));
for (int i = 0; i < nf; i++) {
af[nf-i-1] = get(i);
}
int nb = n - nf;
array<T> ab(max(2*nb, 1));
for (int i = 0; i < nb; i++) {
ab[i] = get(nf+i);
}
front.a = af;
front.n = nf;
back.a = ab;
back.n = nb;
}
}
There is not much to analyze. If the
operation does
rebalancing, then it moves
elements and this takes
time. This is bad, since
is called with each call to
and
. However, the following lemma shows that, on
average,
only spends a constant amount of time per operation.
We will perform our analysis using a technique knows as the
potential method. Define the potential,
, of the
as the difference in size between
and
:
Observe that, immediately after a call to
that shifts
elements, the potential,
, is at most 1, since
Consider the situation immediately before a call to
that
shifts elements and suppose, without loss of generality, that
is shifting elements because
.
Notice that, in this case,
![]() |
![]() |
|||
![]() |
|||
The following theorem summarizes the performance of a
opendatastructures.org