It's been years. You remember that algebra had rules, that exponents did something, that there was a formula with a square root in it — and not much else. This page rebuilds college algebra from the ground up in seven lessons, written for an adult who once knew it. Every lesson says the idea in plain words, works one example by hand, then hands you two problems to do before the answer appears. The Go code is there too, tucked in a drop-down under each lesson, because a computer that agrees with your pencil is the best proof you've got it right. Open it after you've done the maths, not instead.
Reading maths feels like understanding it. It isn't. The only way to know whether you've got an idea back is to use it with nothing in front of you, and then say it out loud in your own words. Every lesson below is built around that, so follow the same five moves each time:
Code is a great way to check maths and a poor way to learn it. A program will happily compute the wrong thing very precisely. So the maths comes first, on paper, and the program is the answer key. Each one is complete: copy it into a folder, go mod init algebra, go run ., and it prints exactly the output shown. If you haven't installed Go yet, Install Go takes ten minutes.
Pencil first. Reveal second. Explain it out loud third. Code last.
An exponent is a count of multiplications: 23 means 2 × 2 × 2. Every exponent rule is a shortcut you could rediscover by writing the multiplications out, which is worth doing once so the rules stop feeling arbitrary. A radical (square root, cube root) is just an exponent that's a fraction, so it obeys the same rules.
| Rule | Says | Because |
|---|---|---|
| Product | am · an = am+n | three 2s times four 2s is seven 2s |
| Quotient | am / an = am−n | cancel n of the 2s top and bottom |
| Power | (am)n = amn | n groups of m 2s |
| Zero | a0 = 1 | an / an = a0, and anything over itself is 1 |
| Negative | a−n = 1 / an | keep subtracting past zero and the 2s move to the bottom |
| Fractional | a1/n = n√a am/n = (n√a)m | (a1/2)2 = a1, so a1/2 must be the square root |
1. x5 − 2 − 1 = x2. 2. Fourth root of 16 is 2, then cubed: 8. 3. 50 = 25 × 2, so 5√2. If you got 25√2 you forgot to take the root of the 25.
A teenager says “anything to the power zero is one” is just a rule someone made up. Convince them it couldn't be anything else, using only the quotient rule.
Take 23 divided by 23. That's 8 over 8, which is obviously 1. But the quotient rule says subtract the exponents: 23 − 3 = 20. Both are the same calculation, so 20 has to be 1 — not because anyone decided it, but because the alternative would make the quotient rule lie. The same trick shows why negative exponents mean “one over”: 23 / 25 is 8/32 = 1/4, and the rule says 2−2, so 2−2 must be 1/4.
math.Pow(a, b) is ab for any real exponent, so every rule in the table can be checked numerically. One thing to notice in the output: 8^(1/3) prints as 1.9999999999999998, not 2. That's not a maths error — it's floating-point arithmetic being honest about the fact that 1/3 can't be stored exactly. math.Cbrt exists precisely so you get an exact 2 when you want a cube root.
package main
import (
"fmt"
"math"
)
func main() {
// product rule: a^m * a^n = a^(m+n)
fmt.Println("2^3 * 2^4 =", math.Pow(2, 3)*math.Pow(2, 4), "= 2^7 =", math.Pow(2, 7))
// quotient rule: a^m / a^n = a^(m-n)
fmt.Println("2^7 / 2^5 =", math.Pow(2, 7)/math.Pow(2, 5), "= 2^2 =", math.Pow(2, 2))
// power rule: (a^m)^n = a^(m*n)
fmt.Println("(3^2)^3 =", math.Pow(math.Pow(3, 2), 3), "= 3^6 =", math.Pow(3, 6))
// negative exponent: a^-n = 1 / a^n
fmt.Println("5^-2 =", math.Pow(5, -2), "= 1/25 =", 1.0/25)
// fractional exponent: a^(1/n) is the nth root
fmt.Println("8^(1/3) =", math.Pow(8, 1.0/3), " cube root:", math.Cbrt(8))
fmt.Println("8^(2/3) =", math.Pow(8, 2.0/3), " (cube root, then squared:", math.Pow(math.Cbrt(8), 2), ")")
// simplify a radical: sqrt(72) = sqrt(36 * 2) = 6 * sqrt(2)
fmt.Printf("sqrt(72) = %.4f 6*sqrt(2) = %.4f\n", math.Sqrt(72), 6*math.Sqrt(2))
}
Expanding is easy: (x + 2)(x + 3) = x2 + 5x + 6, you just multiply everything by everything. Factoring is the same equation read right-to-left, and it's harder because you're guessing what was multiplied. It matters for one reason above all others: if two things multiply to zero, one of them is zero. Factor an expression that equals zero and you've solved the equation.
Four patterns cover almost everything at this level. Always try them in this order:
| 1. Look for | Pattern | Example |
|---|---|---|
| A common factor | ab + ac = a(b + c) | 3x2 − 12x = 3x(x − 4) |
| A difference of squares | a2 − b2 = (a − b)(a + b) | x2 − 25 = (x − 5)(x + 5) |
| A perfect square | a2 ± 2ab + b2 = (a ± b)2 | x2 + 6x + 9 = (x + 3)2 |
| A trinomial x2 + bx + c | (x + p)(x + q) where p·q = c and p + q = b | x2 + 5x + 6 = (x + 2)(x + 3) |
1. Multiply to −15, add to 2: (5, −3). (x + 5)(x − 3). 2. Both terms are squares, 9x2 = (3x)2 and 25 = 52: (3x − 5)(3x + 5). 3. Common factor x: x(x − 5) = 0, so x = 0 or x = 5. The classic mistake is dividing both sides by x and losing the x = 0 answer — never divide by something that might be zero.
Why does rewriting x2 − 7x + 12 as (x − 3)(x − 4) suddenly make it easy to solve when it equals zero, but do nothing useful when it equals 5?
Zero is special: it's the only number you can't get by multiplying two non-zero numbers together. So if (something) × (something else) = 0, you know one of the two brackets is zero, and each bracket is a one-step equation. If the product is 5 instead, the brackets could be 1 and 5, or 2.5 and 2, or a million pairs — the factored form tells you nothing. That's why the first move in solving any quadratic is to get zero on one side.
The program does exactly the by-hand search: for x2 + bx + c it tries every integer p that divides c, sets q = c/p, and stops when p + q = b. When nothing works it says so — which is the honest answer for x2 + x + 1, and your cue to reach for the quadratic formula in the next lesson.
package main import "fmt" // factor x^2 + bx + c by finding two integers whose product is c // and whose sum is b — exactly the by-hand method. func factor(b, c int) (int, int, bool) { for p := -100; p <= 100; p++ { if p == 0 { continue } if c%p == 0 { q := c / p if p+q == b { return p, q, true } } } return 0, 0, false } // signed prints "+ 4" or "- 4" so the output reads like the textbook func signed(n int) string { if n < 0 { return fmt.Sprintf("- %d", -n) } return fmt.Sprintf("+ %d", n) } // terms writes the "+ bx + c" part of the polynomial func terms(b, c int) string { return fmt.Sprintf("%sx %s", signed(b), signed(c)) } func main() { problems := [][2]int{{5, 6}, {-7, 12}, {2, -15}, {1, 1}} for _, pr := range problems { b, c := pr[0], pr[1] p, q, ok := factor(b, c) if ok { fmt.Printf("x^2 %s = (x %s)(x %s)\n", terms(b, c), signed(p), signed(q)) } else { fmt.Printf("x^2 %s does not factor over the integers\n", terms(b, c)) } } }
A quadratic is anything with an x2 in it and nothing higher, and its graph is always a parabola — a U, or an upside-down U if the x2 coefficient is negative. The same parabola can be written three ways, and each form makes one fact obvious:
| Form | Looks like | Shows you at a glance |
|---|---|---|
| Standard | y = ax2 + bx + c | the y-intercept (it's c) |
| Factored | y = a(x − r1)(x − r2) | the roots, where it crosses the x-axis (r1 and r2) |
| Vertex | y = a(x − h)2 + k | the turning point (h, k) — the lowest or highest point |
Lesson 02 got you from standard to factored. Completing the square gets you from standard to vertex form, and it's the technique the quadratic formula is secretly made of.
The shortcut, once you've done it by hand a few times: the vertex is always at x = −b / 2a. Here that's 6/2 = 3. Plug it in for y.
Inside the quadratic formula x = (−b ± √(b2 − 4ac)) / 2a, the part under the root, b2 − 4ac, decides everything:
For x2 − 6x + 5: b2 − 4ac = 36 − 20 = 16, positive, so two roots: x = (6 ± 4)/2 = 5 and 1. Which matches the factoring: (x − 5)(x − 1). Three forms, one parabola.
1. Half of 4 is 2, squared is 4: (x2 + 4x + 4) − 4 − 1 = (x + 2)2 − 5, vertex (−2, −5). Watch the sign: (x + 2) means h = −2. 2. 4 − 20 = −16, negative: none. 3. Vertex at t = −20 / (2 × −5) = 2 seconds; h = −20 + 40 = 20. The negative a means it's an upside-down U, so the vertex is a maximum — the top of the throw.
Without mentioning a formula, explain why a quadratic can have two solutions, or one, or none — using only the picture of a U-shaped curve and a horizontal line.
Solving ax2 + bx + c = 0 means asking where the U crosses the floor (the x-axis). A U that dips below the floor crosses it on the way down and again on the way up — two answers. A U whose bottom just kisses the floor touches it once — one answer. A U that floats above the floor never touches it — no answers. The discriminant is just a number that tells you which of those three pictures you're looking at, without drawing it.
The program computes the vertex with −b/2a, then classifies the discriminant with a switch — the three-way branch is the same three cases as the picture above. Change a, b, c at the top to the coefficients from the “Now you” problems and run it again.
package main
import (
"fmt"
"math"
)
func main() {
// y = ax^2 + bx + c
a, b, c := 1.0, -6.0, 5.0
// vertex: x = -b / 2a, then plug in for y
vx := -b / (2 * a)
vy := a*vx*vx + b*vx + c
fmt.Printf("vertex at (%g, %g)\n", vx, vy)
fmt.Printf("vertex form: y = %g(x - %g)^2 - %g\n", a, vx, -vy)
// discriminant decides how many real roots there are
d := b*b - 4*a*c
fmt.Printf("discriminant b^2 - 4ac = %g\n", d)
switch {
case d > 0:
r1 := (-b + math.Sqrt(d)) / (2 * a)
r2 := (-b - math.Sqrt(d)) / (2 * a)
fmt.Printf("two real roots: x = %g and x = %g\n", r1, r2)
case d == 0:
fmt.Printf("one repeated root: x = %g\n", -b/(2*a))
default:
fmt.Println("no real roots (the parabola never touches the x-axis)")
}
}
A function is a machine: put a number in, get exactly one number out. f(x) = 2x + 3 is the machine's name and its recipe, and f(4) means “put 4 in”: f(4) = 2(4) + 3 = 11. The notation trips people up because f(x) looks like multiplication. It isn't. Read the brackets as “of”: f of 4.
Four things college algebra asks about any function:
1. x = 3y − 1 → y = (x + 1)/3, so f−1(x) = (x + 1)/3. f(2) = 5, f−1(5) = 2 ✓. 2. x2 − 9 = (x − 3)(x + 3) is zero at 3 and −3, so the domain is all real x except 3 and −3. Lesson 02 paid off. 3. (f ∘ g)(2) = f(g(2)) = f(4) = 5; (g ∘ f)(2) = g(f(2)) = g(3) = 6.
Explain to someone who's never seen f(x) what a function is, why f(4) isn't “f times 4,” and what makes something not a function.
A function is a rule that takes a number and gives back exactly one number — like a vending machine where every button gives one specific thing. f is the machine's name and f(4) means “press the 4 button”; the answer is whatever comes out, and that has nothing to do with multiplying. The one rule: the same button must always give the same thing. “The square root of 16” is a function if you agree it means 4, but a rule that says “4 or −4, your choice” isn't one, because pressing the button doesn't tell you what you'll get.
Go functions are values, so f and g are ordinary variables and f(g(4)) is written exactly as in maths. The domain question becomes an error return: h refuses inputs it can't handle and says why, which is the domain made executable.
package main
import (
"fmt"
"math"
)
func main() {
f := func(x float64) float64 { return 2*x + 3 }
g := func(x float64) float64 { return x * x }
fmt.Println("f(4) =", f(4))
fmt.Println("g(4) =", g(4))
// composition: (f o g)(x) = f(g(x)) — do g first, then f
fmt.Println("f(g(4)) =", f(g(4))) // 2*16 + 3
fmt.Println("g(f(4)) =", g(f(4))) // (2*4+3)^2 — order matters
// inverse of f: swap x and y in y = 2x + 3, solve for y -> (x - 3) / 2
fInv := func(x float64) float64 { return (x - 3) / 2 }
fmt.Println("fInv(f(4)) =", fInv(f(4)), " (an inverse undoes the function)")
// domain: h(x) = sqrt(x - 2) / (x - 5) only makes sense for some x
h := func(x float64) (float64, error) {
if x-2 < 0 {
return 0, fmt.Errorf("x=%g: square root of a negative", x)
}
if x == 5 {
return 0, fmt.Errorf("x=%g: division by zero", x)
}
return math.Sqrt(x-2) / (x - 5), nil
}
for _, x := range []float64{1, 3, 5, 11} {
if v, err := h(x); err != nil {
fmt.Println("h(x) undefined:", err)
} else {
fmt.Printf("h(%g) = %g\n", x, v)
}
}
}
An exponential equation has the unknown in the exponent: 2x = 50. You can't get x down with the tools so far. A logarithm is the tool: logb(y) is the exponent you'd need to put on b to get y. log2(8) = 3 because 23 = 8. That's the whole definition; everything else follows from it.
Two bases matter in practice. ln is log base e (e ≈ 2.718, the base that makes calculus tidy), and log without a subscript usually means base 10. Any other base you reach through the change-of-base rule. The three log rules are the three exponent rules from Lesson 01, seen from the other side:
| Log rule | Comes from the exponent rule |
|---|---|
| log(ab) = log a + log b | bm · bn = bm+n |
| log(a/b) = log a − log b | bm / bn = bm−n |
| log(an) = n · log a | (bm)n = bmn |
| logb(y) = ln y / ln b | change of base — lets a calculator with only ln do any base |
1. 2? = 32 → 5. 2. 103 = 1000 so x = 3; ex = 20 gives x = ln 20 ≈ 3.0 (e3 ≈ 20.1, so just under 3). 3. ln(e3) = 3 — ln and e undo each other, they're inverses in the Lesson 04 sense. log5(1) = 0, because 50 = 1; the log of 1 is 0 in every base.
Someone says logarithms are the most confusing thing in algebra. Explain what question a logarithm is asking, in one sentence, and then why it's the thing that solves 2x = 50.
A logarithm asks one question: “how many times do I multiply by this base to reach that number?” log2(50) is “how many doublings get you to 50?” — and 2x = 50 is the exact same question written the other way round. So the log isn't a new trick; it's the name for the answer you were already looking for. Five doublings is 32, six is 64, so the answer is a bit over five and a half, and the log just tells you precisely how much.
math.Log is ln; math.Log10 and math.Log2 exist for those bases, and any other base is math.Log(y) / math.Log(b). The program solves 2x = 50, checks all three log rules numerically, and runs the interest and doubling-time calculations from the worked example.
package main
import (
"fmt"
"math"
)
func main() {
// solve 2^x = 50: take the log of both sides, x = ln(50) / ln(2)
x := math.Log(50) / math.Log(2)
fmt.Printf("2^x = 50 -> x = ln50/ln2 = %.4f check: 2^x = %.4f\n", x, math.Pow(2, x))
// the three log rules, checked numerically
a, b := 8.0, 32.0
fmt.Printf("log(ab) = log a + log b : %.4f = %.4f\n", math.Log(a*b), math.Log(a)+math.Log(b))
fmt.Printf("log(a/b) = log a - log b: %.4f = %.4f\n", math.Log(a/b), math.Log(a)-math.Log(b))
fmt.Printf("log(a^3) = 3 log a : %.4f = %.4f\n", math.Log(math.Pow(a, 3)), 3*math.Log(a))
// compound interest: A = P(1 + r/n)^(nt)
P, r, n, t := 1000.0, 0.06, 12.0, 10.0
A := P * math.Pow(1+r/n, n*t)
fmt.Printf("$%.0f at 6%% monthly for %g years = $%.2f\n", P, t, A)
// how long to double? solve 2 = (1 + r/n)^(nt) for t
years := math.Log(2) / (n * math.Log(1+r/n))
fmt.Printf("doubling time = %.2f years (rule of 72 guess: %.2f)\n", years, 72/6.0)
}
An inequality is solved like an equation — do the same thing to both sides — with one exception that causes nearly every error: multiplying or dividing both sides by a negative number flips the sign. 2 < 3 is true; multiply both sides by −1 and −2 < −3 is false. You have to write −2 > −3.
Absolute value, |x|, is distance from zero, and |x − 3| is distance from 3. Reading it as a distance turns every absolute-value inequality into a sentence:
1. −2x ≤ 6, divide by −2 and flip: x ≥ −3. 2. Within 4 of −1 (note: x + 1 is x − (−1)): −5 < x < 3. 3. More than 1 away from 2: x < 1 or x > 3, i.e. (−∞, 1) ∪ (3, ∞).
Explain why dividing both sides of an inequality by a negative number flips the sign, using a number line and no algebra.
Multiplying by a negative number is a mirror: it reflects every number across zero. Something on the right ends up on the left and vice versa. “2 is less than 3” means 2 sits to the left of 3 on the line. Reflect them both and −2 sits to the right of −3. Their order swapped, so the sign that describes their order has to swap too. Adding or subtracting just slides both numbers along together, which is why those never flip anything.
You can't “solve” an inequality in code the way you solve an equation, but you can do something better for a refresher: test candidate values against the original inequality and against your solution, side by side. If the two columns ever disagree, your solution is wrong. math.Abs is absolute value.
package main
import (
"fmt"
"math"
)
func main() {
// -3x + 6 > 15 -> -3x > 9 -> x < -3 (dividing by -3 FLIPS the sign)
// test a few values against the ORIGINAL inequality to confirm
fmt.Println("-3x + 6 > 15 ?")
for _, x := range []float64{-5, -4, -3, -2, 0} {
fmt.Printf(" x = %2g: %-5v (x < -3 says %v)\n", x, -3*x+6 > 15, x < -3)
}
// |x - 3| < 5 means x is within 5 of 3 -> -2 < x < 8
fmt.Println("|x - 3| < 5 ?")
for _, x := range []float64{-3, -2, 0, 3, 7, 8, 9} {
fmt.Printf(" x = %2g: %-5v (-2 < x < 8 says %v)\n", x, math.Abs(x-3) < 5, -2 < x && x < 8)
}
// |2x + 1| >= 7 splits into two cases: 2x+1 >= 7 or 2x+1 <= -7
// x >= 3 or x <= -4
fmt.Println("|2x + 1| >= 7 ?")
for _, x := range []float64{-5, -4, -3, 0, 2, 3, 4} {
fmt.Printf(" x = %2g: %-5v (x <= -4 or x >= 3 says %v)\n", x, math.Abs(2*x+1) >= 7, x <= -4 || x >= 3)
}
}
A sequence is a list of numbers made by a rule; a series is what you get when you add them up. College algebra covers the two rules that come up everywhere — in loans, in savings, in anything that grows by the same amount or the same factor each step:
| Arithmetic — add d each time | Geometric — multiply by r each time | |
|---|---|---|
| Example | 5, 8, 11, 14, … (d = 3) | 3, 6, 12, 24, … (r = 2) |
| nth term | an = a1 + (n − 1)d | an = a1 · rn−1 |
| Sum of n terms | Sn = n(a1 + an) / 2 | Sn = a1(1 − rn) / (1 − r) |
| Sum of infinitely many | never finite | S = a1 / (1 − r), only if |r| < 1 |
The arithmetic sum formula has a story worth knowing: it's “number of terms times the average term,” and the average of an evenly spaced list is just the average of the first and last. That's the whole formula. The geometric one is less obvious, so the Go program checks it with a plain loop.
1. d = 5, so 2 + 14 × 5 = 72. 2. First 1, last 19, ten terms: 10 × (1 + 19)/2 = 100. (The first n odd numbers always sum to n2.) 3. a1 = 0.9, r = 0.1: S = 0.9 / 0.9 = 1. Which is to say, 0.999… repeating is exactly 1, not “almost.”
How can adding up infinitely many positive numbers give a finite answer like 2? Explain it without the formula.
Stand two metres from a wall and take a step that covers half the remaining distance. Then half of what's left. Then half again. You take infinitely many steps, every one of them a real forward step, and you never reach the wall — but you also never get past two metres, and you get as close as anyone cares to measure. The total of all those steps is exactly the distance to the wall. That only works because the steps shrink fast enough; if each step were the same size, or shrank too slowly, you'd walk forever. “|r| less than 1” is the rule for shrinking fast enough.
The program applies each formula and then does the same sum with a for loop, so the formula and the brute force sit next to each other in the output. The last line shows the infinite series creeping up on 2: twenty terms get you to 1.999998.
package main
import "fmt"
func main() {
// arithmetic: 5, 8, 11, ... a1 = 5, d = 3
a1, d := 5.0, 3.0
n := 20.0
an := a1 + (n-1)*d // nth term
sum := n / 2 * (a1 + an) // sum of the first n terms
fmt.Printf("arithmetic: 20th term = %g, sum of 20 terms = %g\n", an, sum)
// check the sum formula with a plain loop
loop := 0.0
for i := 0; i < 20; i++ {
loop += a1 + float64(i)*d
}
fmt.Printf(" loop says %g\n", loop)
// geometric: 3, 6, 12, ... a1 = 3, r = 2
g1, r := 3.0, 2.0
gn := g1 * pow(r, 10-1) // 10th term
gsum := g1 * (1 - pow(r, 10)) / (1 - r) // sum of the first 10 terms
fmt.Printf("geometric: 10th term = %g, sum of 10 terms = %g\n", gn, gsum)
// infinite geometric series converges when |r| < 1: S = a1 / (1 - r)
fmt.Printf("1 + 1/2 + 1/4 + 1/8 + ... = %g\n", 1/(1-0.5))
partial := 0.0
for i := 0; i < 20; i++ {
partial += pow(0.5, float64(i))
}
fmt.Printf(" first 20 terms add to %.6f\n", partial)
}
func pow(base, exp float64) float64 {
result := 1.0
for i := 0; i < int(exp); i++ {
result *= base
}
return result
}
Seven lessons is a refresher, not a course. If a lesson felt shaky, the fix is the same one this page is built on: close it, take a blank sheet, and re-derive the worked example from memory. What you can rebuild, you own.
The gentler companion page: linear equations, 2×2 systems, the quadratic formula and lines, each worked by hand and then in Go. Good if Lesson 02 or 03 here moved too fast.
Where the exponents and logs from Lessons 01 and 05 turn up in programming: why a log n algorithm barely notices a million items and an n2 one chokes on ten thousand.
If the Go in the drop-downs made you curious rather than nervous, this is the place to actually learn the language — by building something and then rebuilding it smaller.