Why this matters
Earlier in this module you printed squares, triangles and diamonds by choosing a number of stars for each row. Many shapes follow the same idea. A row has a width, the width changes from one row to the next by a fixed amount, and each row is centred so the shape looks even. An hourglass narrows and then widens again. An arrow grows into a head and then stays at one width for a shaft. A cross is a grid where only the middle row and the middle column are filled.
These shapes are a good next step for a beginner because they use only the two loop tools you already know: a loop that goes through rows, and a loop that goes through the characters in one row. Once you can print a shape, you can also check it. If the output looks wrong, you can compare each row against a short list of widths written on paper.
This lesson uses C++ with the standard output stream std::cout. Compile the samples with g++ -std=c++20 -Wall. The output shown under each sample was produced by running the sample on g++ 15.2.
The idea: a width that changes by a step
Start with a number pattern, not with code. Suppose the top row of a shape has one star, the next row has three, the next has five, and so on. The width goes up by 2 each time. Write the widths down:
row 0: 1
row 1: 3
row 2: 5
row 3: 7The change from one row to the next is the step. Here the step is 2. A step is the amount the width moves each time you go down one row. If you know the first width, the step and the row number, you know every width in the shape. There is no need to store the widths in a list.
The same idea works going the other way. If the widths go 7, 5, 3, 1, the step is minus 2. A shape that goes down and then back up uses both directions. The width drops by the step until the middle row, then rises by the step again.
Why each row must be centred
If you print each row with its stars starting at column 0, the shape leans to the left. To make the shape symmetric, each row needs some spaces before its stars. The number of spaces is the difference between the widest row and this row, split in half:
pad = (widest - width) / 2Here / is integer division. In C++, dividing two int values throws away the fraction, so 3 divided by 2 is 1, not 1.5. The parity mistake near the end of this lesson shows why this matters.
For the widths 1, 3, 5, 7, the widest row is 7. The padding is 3, 2, 1, 0. The printed rows are:
*
***
*****
*******Each row is exactly centred, and the spaces shrink by one per row because the width grows by two.
Two loops, one after the other
Before writing the whole shape, write one row. A row is printed in three parts: some spaces, some stars, then a newline. The spaces and the stars each need a loop that counts. The loops are not nested. They run one after the other inside the same function.
#include <iostream>
// Prints one row of `width` stars, centred in a field of `field` columns.
void print_row(int width, int field) {
int pad = (field - width) / 2;
for (int i = 0; i < pad; ++i) {
std::cout << ' ';
}
for (int i = 0; i < width; ++i) {
std::cout << '*';
}
std::cout << '\n';
}
int main() {
print_row(3, 7);
print_row(5, 7);
print_row(7, 7);
}Output:
***
*****
*******The function print_row has no loop over rows. It only draws one row. The loop over rows goes in main. This split is useful. You can test print_row alone, and you can reuse it for any shape that has rows of stars.
Notice the name field. It is the width of the space the whole shape fits in, not the width of this one row. Using the wrong number here is one of the most common mistakes, and it is shown later in this lesson.
Worked example 1: the hourglass
An hourglass is the widest at the top and the bottom, and the narrowest in the middle. Take the middle row as the reference. Every row is some distance from the middle, and the width depends on that distance.
Here are the seven rows of a small hourglass with three rows above the middle. The middle row is row 3, counting from 0.
row 0: distance 3, width 7
row 1: distance 2, width 5
row 2: distance 1, width 3
row 3: distance 0, width 1
row 4: distance 1, width 3
row 5: distance 2, width 5
row 6: distance 3, width 7The width is 1 + step * distance, with a step of 2. The distance is the size of row - middle with no minus sign. In C++ you get that with std::abs, which is defined in <cstdlib>.
The complete program is below. It has one loop over rows, and it calls print_row for each one.
#include <cstdlib>
#include <iostream>
// Prints one row of `width` stars, centred in a field of `field` columns.
void print_row(int width, int field) {
int pad = (field - width) / 2;
for (int i = 0; i < pad; ++i) {
std::cout << ' ';
}
for (int i = 0; i < width; ++i) {
std::cout << '*';
}
std::cout << '\n';
}
int main() {
const int half = 3; // rows above the middle row
const int step = 2; // the width changes by this much per row
const int widest = 1 + step * half;
for (int row = 0; row <= 2 * half; ++row) {
int distance = std::abs(row - half); // 3, 2, 1, 0, 1, 2, 3
int width = 1 + step * distance; // 7, 5, 3, 1, 3, 5, 7
print_row(width, widest);
}
}Output:
*******
*****
***
*
***
*****
*******Three details matter here.
The loop runs from row = 0 to row <= 2 * half. That is seven rows, from 0 to 6. The <= is correct because the last row is 6, which is 2 * half. If you wrote row < 2 * half, the last row would be missing and the bottom of the hourglass would be cut off.
The constants half, step and widest are written once, at the top. If you want a taller hourglass, you change half. If you want a steeper one, you change step. The rest of the code does not change.
The value widest is 1 + step * half, which is 7 here. It is the field width for every row, so every row is centred in the same space. The widest row fills the whole field, and the narrowest row sits in the middle.
Checking one row by hand
Pick row 2. Its distance from the middle is |2 - 3|, which is 1. The width is 1 + 2 * 1, which is 3. The field is 7. The pad is (7 - 3) / 2, which is 2. So the row has two spaces, then three stars:
***That matches the output. When a row looks wrong, do this check. Write the distance, the width, the pad, and then compare with the printed line.
Worked example 2: the arrow
An arrow has two parts. The head is a triangle that grows, and the shaft is a block of a fixed width. The head grows by a step of 2 per row. The shaft does not change width.
Suppose the head has four rows and the shaft has two rows of width 3. The head widths are 1, 3, 5 and 7. The shaft widths are 3 and 3. Every row is centred in a field of width 7, which is the widest row of the head.
#include <iostream>
// Prints one row of `width` stars, centred in a field of `field` columns.
void print_row(int width, int field) {
int pad = (field - width) / 2;
for (int i = 0; i < pad; ++i) {
std::cout << ' ';
}
for (int i = 0; i < width; ++i) {
std::cout << '*';
}
std::cout << '\n';
}
void print_arrow(int head_rows, int shaft_rows, int shaft_width) {
const int field = 2 * head_rows - 1; // width of the widest head row
for (int row = 0; row < head_rows; ++row) {
print_row(1 + 2 * row, field); // the head grows by 2 per row
}
for (int row = 0; row < shaft_rows; ++row) {
print_row(shaft_width, field); // the shaft keeps one width
}
}
int main() {
print_arrow(4, 2, 3);
}Output:
*
***
*****
*******
***
***The function has two loops, one after the other. The first loop draws the head, and its width depends on the row. The second loop draws the shaft, and its width is fixed. Both loops call the same print_row, so both parts are centred the same way.
The field is 2 * head_rows - 1. For four head rows, that is 7. The shaft is centred inside the same field, which is why the shaft starts two columns in, not at column 0.
Try the shape with a shaft width of 1 and then 5. With a width of 5 the pad is 1, so the shaft starts one column in. With a shaft width of 7 the pad is 0, so the shaft is as wide as the head base.
Worked example 3: the cross, a grid with a condition
A cross is different from the shapes above. Each row does not have one run of stars. A row can have a star in one place and dots in the rest. The easiest way to print it is a grid. A grid needs two nested loops: the outer loop walks through the rows, and the inner loop walks through the columns. For each cell, a condition decides whether to print a star or a dot.
A cell is on the cross if its row is the middle row or its column is the middle column. In a grid of size 7, the middle index is 3, because the indexes run from 0 to 6.
#include <iostream>
int main() {
const int size = 7;
const int mid = size / 2; // 3, the index of the centre row and column
for (int row = 0; row < size; ++row) {
for (int col = 0; col < size; ++col) {
bool on_arm = (row == mid) || (col == mid);
std::cout << (on_arm ? '*' : '.');
}
std::cout << '\n';
}
}Output:
...*...
...*...
...*...
*******
...*...
...*...
...*...The dots show the empty cells. They are printed so you can see the grid. A real cross can use spaces instead, but spaces are hard to see in a lesson, and a trailing space at the end of a line is easy to miss when you compare two outputs.
The condition (row == mid) || (col == mid) is written with ||, which means "or". A cell is starred if either part is true. If you used &&, which means "and", only the single centre cell would be starred.
Why this is the same kind of problem
The grid version and the row-by-row version use the same tools. The outer loop goes through rows and the inner loop goes through characters. The difference is the decision. In the hourglass, the decision is "how many stars in this row?" In the cross, the decision is "is this cell a star or a dot?" When a shape is not solid, a grid with a condition is usually the simplest approach.
Going further: a hollow hourglass
A hollow shape has an outline and an empty inside. The hourglass above is solid. To make it hollow, combine the two ideas from this lesson. Each row still has a width and a pad, so you know where its left edge and right edge are. A cell is then starred only if it is on the outline. The outline is the top row, the bottom row, the left edge or the right edge.
The program below first works out the edges of each row, then checks every cell in the row with one condition. The empty cells are printed as dots so you can see them.
#include <cstdlib>
#include <iostream>
int main() {
const int half = 3;
const int step = 2;
const int widest = 1 + step * half; // 7
for (int row = 0; row <= 2 * half; ++row) {
int width = 1 + step * std::abs(row - half);
int pad = (widest - width) / 2;
int left = pad; // column of the left edge
int right = pad + width - 1; // column of the right edge
bool top_or_bottom = (row == 0) || (row == 2 * half);
for (int col = 0; col < widest; ++col) {
bool filled = (col >= left && col <= right) &&
(top_or_bottom || col == left || col == right);
std::cout << (filled ? '*' : '.');
}
std::cout << '\n';
}
}Output:
*******
.*...*.
..*.*..
...*...
..*.*..
.*...*.
*******The first condition, col >= left && col <= right, keeps the cell inside the row. The second condition keeps only the outline. A cell on the top or bottom row is filled across its whole width. A cell on any other row is filled only at its left or right edge. The middle row has width 1, so its left and right edges are the same cell, and that one star is the whole row.
This version combines three tests, and each one is short. Writing the test in words first makes the code easier to write: "inside the row, and either on the top or bottom row, or on the edge".
Common mistakes
Forgetting the distance is positive
In the hourglass, the width depends on the distance from the middle. Above the middle, row - half is negative. If you use it without std::abs, the widths for the top rows become negative numbers. A negative width does not print any stars, so the top rows are blank.
int width = 1 + step * (row - half); // wrong: negative above the middleThis compiles without any warning. Running the program shows the top three rows as lines of spaces, with no stars:
*
***
*****
*******The spaces at the start of the first three lines are real. They come from the pad, which is (7 - (-5)) / 2, which is 6. The stars are missing because the loop that prints them runs zero times for a negative width. If you see blank lines in a shape, check the widths first.
Using a step that makes the widths alternate in parity
The parity of a number is whether it is odd or even. The widths in the hourglass are odd, so every row can be centred with an exact number of spaces on each side. If you change the step to 1, the widths alternate between odd and even. The field is 4, and the pad for an odd width is rounded down by integer division.
Changing step to 1 in the hourglass program gives this output:
****
***
**
*
**
***
****The rows of three stars and four stars start at different columns from the rows of two stars and one star. The shape is still symmetric, but the centres do not line up on the same column. A shape with an even step has this problem. For shapes where the centre should land between two columns, you need to decide which side gets the extra space and say so in the code.
Using the wrong field width
Every row must be centred in the same field. If you use the width of the current row as the field, every row has a pad of 0 and the shape is drawn flush left. The fix is to compute widest once, outside the loop, and pass it to print_row every time.
A signed and unsigned loop counter
A common compiler warning appears when a loop counter has an unsigned type and the limit is a signed int. The unsigned type is std::size_t, which is used for sizes and indexes in the standard library. Here is an example:
#include <cstddef>
#include <iostream>
int main() {
int rows = 4;
for (std::size_t r = 0; r < rows; ++r) {
std::cout << r << '\n';
}
}Compiling with g++ -std=c++20 -Wall gives this warning:
sign.cpp: In function 'int main()':
sign.cpp:6:31: warning: comparison of integer expressions of different signedness: 'std::size_t' {aka 'long unsigned int'} and 'int' [-Wsign-compare]
6 | for (std::size_t r = 0; r < rows; ++r) {
| ~~^~~~~~The fix is to use the same type for both sides. For shape code, int is the usual choice, because the widths can be negative during a bad calculation and you want to see that as a number, not as a very large unsigned value.
Practice
- What does
print_row(3, 7)print? Count the spaces before the stars.
Show the answer
It prints two spaces and then three stars: ***. The pad is (7 - 3) / 2, which is 2.
- For an hourglass with
half = 2andstep = 2, what are the widths of the five rows, from top to bottom?
Show the answer
The widths are 5, 3, 1, 3, 5. The distance for the rows is 2, 1, 0, 1, 2, and the width is 1 + 2 * distance.
- Change the cross so its arms are three cells thick, in a grid of size 9. The middle row and column stay in the centre, and each arm is one cell wider on each side.
Show the answer
Use a condition that accepts cells within one step of the centre:
const int size = 9;
const int mid = size / 2; // 4
for (int row = 0; row < size; ++row) {
for (int col = 0; col < size; ++col) {
bool on_arm = std::abs(row - mid) <= 1 || std::abs(col - mid) <= 1;
std::cout << (on_arm ? '*' : '.');
}
std::cout << '\n';
}Output:
...***...
...***...
...***...
*********
*********
*********
...***...
...***...
...***...The program needs #include <cstdlib> for std::abs.
- Print a diamond with a widest row of 7, by reusing the hourglass idea with the step reversed. The widths are 1, 3, 5, 7, 5, 3, 1.
Show the answer
Subtract the step times the distance from the widest width, not add it:
const int half = 3;
const int widest = 1 + 2 * half; // 7
for (int row = 0; row <= 2 * half; ++row) {
print_row(widest - 2 * std::abs(row - half), widest);
}Output:
*
***
*****
*******
*****
***
*- Without running the program, add the widths of the hourglass with
half = 3andstep = 2. Then check your answer by running the program.
Show the answer
The widths are 7, 5, 3, 1, 3, 5, 7. Their total is 31. The program that adds them up, using the same loop as the hourglass, prints 31.
Summary
- A shape built from rows is a list of widths, and a fixed step gives you each width from the row number.
- Centre each row by printing
(field - width) / 2spaces before its stars, using one field for the whole shape. - Use
std::absfor the distance from the middle, so the top and bottom halves follow the same rule. - Two loops that run one after the other can draw one row, and a loop over rows calls them for each row.
- A shape with holes, such as a cross, is a grid. Use two nested loops and a condition for each cell.
- When a shape looks wrong, check the widths, the pad and the field on paper before you change the loop limits.