Problems:
CS100 Homework 6 (Spring 2023)
There will be four problems in total: one set of objective questions and three programming problems.
(May 4 22:22) Problem 2 has been added.
(May 9 02:38) Problem 3 has been added.
(May 9 07:12) Problem 4 has been added.
Unit Test: Inheritance and Operator Overloading
This problem is to answer questions.
👉 Go to GitHub for the HTML page file
Dynamic Array 3.0
This problem is based on the Dynarray
you wrote in Homework 6 Problem 2. Before adding anything new to it, your Dynarray
should meet all the requirements in that problem first.
In this task, your Dynarray
should support the following new things.
Subscript operator
The Dynarray
should support the subscript operator, so that we can use a[i]
instead of a.at(i)
to access the i-th element.
Let a
be an object of type Dynarray
or const Dynarray
. The behavior of a[i]
should be exactly the same as a.at(i)
, except that the subscript operator does not perform bounds checking. That is, no exception should be thrown if i >= a.size()
.
Relational operators
The Dynarray
should support the six relational operators: <
, <=
, >
, >=
, ==
and !=
. These operators perform lexicographical comparison of two Dynarray
.
Lexicographical comparison is an operation with the following properties:
- Two ranges are compared element by element.
- The first mismatching element defines which range is lexicographically less or greater than the other.
- If one range is a prefix of another, the shorter range is lexicographically less than the other.
- If two ranges have equivalent elements and are of the same length, then the ranges are lexicographically equal.
- An empty range is lexicographically less than any non-empty range.
- Two empty ranges are lexicographically equal.
Since we use C++17, you still have to define all six of them. It is often good practice to implement operator<
and operator==
first, and define the rest in terms of them.
Note that in homework 8, we will make this Dynarray
a class template Dynarray<T>
, and we should always minimize the requirements on unknown types when we do generic programming. Since C++17 does not have compiler-generated comparison operators, we suggest that your implementation depend only upon the operator<
and operator==
of the element type.
You are free to choose to define them as either members or non-members.
Output operator
We want to print a Dynarray
directly using operator<<
. For example,
1 | int arr[] = {1, 2, 3, 5}; |
The output is as follows.
1 | [1, 2, 3, 5] |
In details:
- Elements are separated by a comma (
,
) followed by a space. - The printed content starts with
[
and ends with']'
. If the dynamic array is empty, just print an empty pair of brackets[]
.
OJ tests
There are three subtasks on OJ. Subtask will be run only if all the testcases of subtask are passed.
Subtask 1 is a compile-time check. Subtask 2 contains all the testcases from Homework 5 Problem 3 and Homework 6 Problem 2. Subtask 3 contains the new testcases specific for this problem.
Polynomial
In this task, you will design and implement a class Polynomial
representing a real polynomial of the following form:
where are the coefficients and . The degree of a polynomial is the highest degree of the terms with non-zero coefficients, denoted by
In particular, the following corner cases are defined.
- even if .
- A polynomial has at least one coefficient. When , the polynomial becomes a constant function whose degree is , even if .
There are a number of operations that can be performed on polynomials, including addition, subtraction, multiplication, etc. To ensure that the coefficient of the highest-order term is always non-zero, we may need a function adjust
to remove the unnecessary trailing zeros.
1 | class Polynomial { |
Considering potential floating-point errors, it is suggested to use the function isZero(x)
to determine whether x
is zero, which is effectively for some very small .
The coefficients, of course, should be stored in a sequential container, and std::vector
is a perfect choice.
Constructors and copy control
The class Polynomial
should meet the following requirements.
-
Default-constructible. A
Polynomial
can be default-initialized to the constant function . -
Copyable, movable and destructible. A
Polynomial
should have a copy constructor, a move constructor, a copy assignment operator, a move assignment operator and a destructor. These functions should perform the corresponding operations directly on the memberm_coeffs
. Before starting to define them, think about the following questions:- Will the compiler generate these functions for you if you do not define them?
- What are the behaviors of the compiler-generated functions? Do they meet the requirements?
Note: Direct moving of the member
m_coeffs
will possibly make the moved-from polynomial have no coefficients (itsm_coeffs
will possibly be empty), which is not in a valid state if we require allPolynomial
s to have at least one coefficient. But considering that this is not the concentration of this problem, we only require that the moved-from object can be safely assigned to or destroyed, which means that the compiler-generated move operations suffice. The move operations ofPolynomial
are not required to benoexcept
here. You are free to choose any reasonable design. -
Constructible from a pair of InputIterators. This function has been implemented for you, as long as your
adjust()
is correct.1
2
3template <typename InputIterator>
Polynomial(InputIterator begin, InputIterator end)
: m_coeffs(begin, end) { adjust(); }This allows the following ways of constructing a
Polynomial
:1
2
3
4
5
6
7
8double a[] = {1, 2, -1, 3.5};
std::vector b{2.5, 3.3, 0.0};
std::list c{2.7, 1.828, 3.2}; // not a vector, but have iterators
std::deque<double> d; // empty
Polynomial p1(a, a + 4); // 1 + 2x - x^2 + 3.5*x^3
Polynomial p2(b.begin(), b.end()); // 2.5 + 3.3x, the trailing zero removed
Polynomial p3(c.begin(), c.end()); // 2.7 + 1.828x + 3.2*x^2
Polynomial p4(d.begin(), d.end()); // 0If the iterator range
[begin, end)
is empty, i.e.begin == end
, this constructor initializes thePolynomial
to beP(x)=0
. -
Constructible from a
const std::vector<double>
, which contains the coefficients.1
2
3
4
5std::vector a{2.5, 3.3, 4.2, 0.0, 0.0};
std::vector<double> b; // empty
Polynomial p1(a); // 2.5 + 3.3x + 4.2*x^2.
Polynomial p2(b); // 0
Polynomial p3(std::move(a)); // Move it, instead of copying it.Note that if the argument passed in is a non-
const
rvalue, it should be moved instead of being copied. Moreover, implicit conversion from astd::vector<double>
toPolynomial
through this constructor should be disabled.
Basic operations
Let p
be of type Polynomial
and cp
be of type const Polynomial
. The following operations should be supported.
-
Both
p.deg()
andcp.deg()
return the degree of the polynomial. Any reasonable integral return type is allowed, but it should not be too small. -
For an integer
i
, bothp[i]
andcp[i]
return the coefficient of the term as a read-only number. Any reasonable return type is allowed. The behavior is undefined ifi
is not in the valid range[0, deg()]
. -
For an integer
i
and a real numberc
,p.setCoeff(i, c)
sets the coefficient of the term toc
. The behavior is undefined ifi < 0
. Ifi > deg()
, it has the same effect as adding a monomial to it, which means that after this operation,p.deg()
becomesi
.p[j]
is zero for everyj
in(d, i)
, whered
is the degree ofp
before this operation.
Note that allowing the user to modify the coefficients may result in trailing zeros, which your code must handle correctly. This is why we don't allow
p[i]
to return the non-const
reference to thei
-th coefficient.std::vector<T>::resize
may help you. -
operator==
andoperator!=
should be defined. Two polynomialsa
andb
are equal if and only ifa - b
is the zero constant function . Equivalently,a == b
if and only ifa.deg() == b.deg()
, andisZero(a[i] - b[i])
holds for every integeri
in[0, a.deg()]
.
-
For a number
x0
(suppose it is adouble
), bothp(x0)
andcp(x0)
return the value of the polynomial at , i.e. .
Arithmetic operations
The arithmetic operators (as well as the corresponding compound assignment operators) that need to be supported are as follows.
-a
(the unary negation operator),a + b
,a += b
,a - b
,a -= b
.a * b
,a *= b
.
Derivatives and integrals
For a polynomial p
,
-
p.derivative()
should return the derivative ofp
, i.e. . Note that this should still be a polynomial. -
p.integral()
should return the integral ofp
, defined asNote that this should still be a polynomial.
Notes
For each member function, should it be const
, or non-const
, or having the "const
vs non-const
overloads"? Think about these questions carefully before coding.
Regarding the floating-point errors: we will not test this on purpose, but such problems might show up accidentally and cause unexpected results. To avoid such risks, it is suggested to always use isZero(x)
and isZero(a - b)
, instead of directly comparing floating-point numbers.
Compile-time requirements test
We have provided a compile-time test compile_test.cpp
for you.
Attachments
Expression
In recitations, we showed an example of designing a class hierarchy for different kinds of nodes in an abstract syntax tree (AST). In this problem, we will support more functionalities:
- A new kind of node called
Variable
, which represents the varaiblex
. With this supported, our AST can be used to represent unary functions, instead of only arithmetic expressions with constants. - Evaluation of the function at a certain point .
- Evaluation of the derivative of the function at a certain point .
The operations that need to be supported are declared in this base class:
1 | class NodeBase { |
In recitations, we used only one class BinaryOperator
to represent four different kinds of binary operators. Such design may not be convenient for this problem, because the ways of evaluating the function and its derivative differ to a greater extent between different operators. Therefore, we separate them into four classes:
1 | class BinaryOperator : public NodeBase { |
By declaring using BinaryOperator::BinaryOperator;
, we are inheriting the constructor from BinaryOperator
. For example, the class PlusOp
now has a constructor like this:
1 | class PlusOp : public BinaryOperator { |
and the same for MinusOp
, MultiplyOp
and DivideOp
.
To support the variable x
, we define a new class Variable
:
1 | class Variable : public NodeBase { |
Then we define a static
member of Expr
as follows.
1 | class Expr { |
Note that Expr
also has a non-explicit
constructor that receives a double
and creates a Constant
node:
1 | Expr::Expr(double value) : m_ptr{std::make_shared<Constant>(value)} {} |
With all of these, and the overloaded arithmetic operators defined, we can create functions in a very convenient manner:
1 | auto &x = Expr::x; |
Requirements for rep()
Note: This is a very simple and naive way to convert an expression to a string, because we don't want to set barriers on this part. You can search for some algorithms to make the expression as simplified as possible, but it may not pass the OJ tests.
Any operand of the unary operators ( +
, -
) or the binary operators ( +
, -
, *
, /
) should be surrounded by a pair of parentheses. Correct examples:
- :
(2.000000) + (3.000000)
- :
((2.000000) * (x)) + (3.000000)
- :
((2.000000) * (-(x))) + (3.000000)
There should be a space before and after each binary operator. For example, (expr1) + (expr2)
instead of (expr1)+(expr2)
.
To convert a floating-point number to std::string
, just use std::to_string
and you will be free of troubles caused by precisions and floating-point errors.
Note: If the floating-point value of a Constant
node is negative, it is not treated as a unary minus sign applied to its absolute value. For example,
1 | Expr e(-2); |
The output should be -2.000000
instead of -(2.000000)
.
example.cpp
contains these simple tests.
Requirements for Expr
From the user's perspective, only Expr
and its arithmetic operators are interfaces. Anything else in your code is implementation details, which user code will not rely on. In other words, you are free to implement these things in any way, as long as the interfaces of Expr
meet our requirements.
Expr
is copy-constructible, copy-assignable, move-constructible, move-assignable and destructible. These operations should just perform the corresponding operations on the memberm_ptr
, and let the corresponding function ofstd::shared_ptr
handle everything. The move operations should benoexcept
.Expr
is constructible from adouble
value, and this constructor is non-explicit
.- Let
e
beconst Expr
andx0
bedouble
, then the subtree rooted ate
represents a function.e.eval(x0)
returns the value of this function at .e.derivative(x0)
returns the value of the derivative of this function at .e.rep()
returns astd::string
representing this function. - Let
e1
ande2
be two objects of typeconst Expr
. The following expressions return an object of typeExpr
, which creates a new node corresponding to the operators.-e1
+e1
e1 + e2
e1 - e2
e1 * e2
e1 / e2
Expr::x
is an object of typeconst Expr
, which represents the variablex
.
Use compile_test.cpp
to check whether your code compiles.
Submission
Submit expr.hpp
or its contents to OJ.
Thinking
Why do we use std::shared_ptr
instead of std::unique_ptr
?
Can you add support for more functionalities, e.g. the elementary functions , , exponential expressions , ...? More variables? Print expressions to ?