Дерево Штерна–Броко та послідовності Фарея
Дерево Штерна–Броко
Дерево Штерна–Броко — це елегантна конструкція для представлення множини всіх додатних дробів. Воно було незалежно відкрите німецьким математиком Моріцом Штерном у 1858 році та французьким годинникарем Ашилем Броко у 1861 році. Утім, деякі джерела приписують це відкриття давньогрецькому математику Ератосфену.
- Чи працюєш із дробами () та потрібен їх упорядкований нескоротний перелік або пошук?
- Чи задачу можна звести до бінарного пошуку дробу за предикатом «»? (якщо потрібне саме розкладання дробу — див. ланцюгові дроби)
- Чи допустима логарифмічна глибина спуску — або потрібен крок run-length, щоб уникнути ?
Конструкція починається на нульовій ітерації з двох дробів
де варто зауважити, що друга величина строго кажучи не є дробом, але її можна трактувати як нескоротний дріб, що представляє нескінченність.
На кожній наступній ітерації ми розглядаємо всі сусідні дроби і та вставляємо між ними їхню медіанту .
Перші кілька ітерацій виглядають так:
Продовжуючи цей процес до нескінченності, ми покриваємо всі додатні дроби. Крім того, усі дроби будуть унікальними та нескоротними. Нарешті, дроби також з'являтимуться у порядку зростання.
Перш ніж доводити ці властивості, покажемо саме візуалізацію дерева Штерна–Броко, а не представлення у вигляді списку. Кожен дріб у дереві має двох нащадків. Кожен нащадок є медіантою найближчого предка зліва та найближчого предка справа.
Доведення
Упорядкованість. Довести впорядкованість просто. Зауважимо, що медіанта двох дробів завжди лежить між цими дробами
за умови, що
Обидві нерівності легко показати, переписавши дроби зі спільними знаменниками.
Оскільки на нульовій ітерації порядок зростаючий, він зберігатиметься на кожній наступній ітерації.
Нескоротність. Щоб довести це, ми покажемо, що для будь-яких двох сусідніх дробів і виконується
Пригадаймо, що діофантове рівняння з двома змінними має розв'язок тоді й лише тоді, коли кратне . У нашому випадку це означає, що , що ми й хочемо показати.
Очевидно, що на нульовій ітерації . Лишається показати, що медіанти зберігають цю властивість.
Припустимо, що наші два сусідні дроби задовольняють . Після додавання медіанти до списку
нові вирази набувають вигляду
що, використовуючи , легко показати як істинне.
Звідси ми бачимо, що ця властивість завжди зберігається, а отже, усі дроби нескоротні.
Наявність усіх дробів. Це доведення тісно пов'язане з пошуком розташування дробу в дереві Штерна–Броко. З властивості впорядкованості ми маємо, що ліве піддерево дробу містить лише дроби, менші за батьківський дріб, а праве піддерево містить лише дроби, більші за батьківський дріб. Це означає, що ми можемо шукати дріб, обходячи дерево від кореня: ідучи ліворуч, якщо ціль менша за дріб, і праворуч, якщо ціль більша.
Виберемо довільний додатний цільовий дріб . Він очевидно лежить між і , тож єдиний спосіб, у який цей дріб може бути відсутнім у дереві, — це якщо для досягнення його знадобиться нескінченна кількість кроків.
Якби це було так, то на всіх ітераціях ми мали б
що (використовуючи той факт, що ціле ) можна переписати як
Тепер помножимо першу нерівність на , а другу — на і додамо їх, отримавши
Розкривши це і використавши раніше показану властивість , ми отримуємо, що
А оскільки на кожній ітерації принаймні одна з величин зростатиме, процес пошуку дробу міститиме не більше ніж ітерацій. Це суперечить припущенню, що шлях до був нескінченним, а отже, має бути частиною дерева.
Алгоритм побудови дерева
Щоб побудувати будь-яке піддерево дерева Штерна–Броко, достатньо знати лівого і правого предка. На першому рівні лівим і правим предками є і відповідно. Використовуючи їх, ми обчислюємо медіанту і спускаємося на один рівень глибше, причому медіанта замінює правого предка в лівому піддереві, і навпаки.
Цей псевдокод намагається побудувати все нескінченне дерево:
- C++
- Python
- TypeScript
- Go
void build(int a = 0, int b = 1, int c = 1, int d = 0, int level = 1) {
int x = a + c, y = b + d;
... вивести поточний дріб x/y на поточному рівні дерева
build(a, b, x, y, level + 1);
build(x, y, c, d, level + 1);
}
def build(a=0, b=1, c=1, d=0, level=1):
x, y = a + c, b + d
... # вивести поточний дріб x/y на поточному рівні дерева
build(a, b, x, y, level + 1)
build(x, y, c, d, level + 1)
function build(a = 0, b = 1, c = 1, d = 0, level = 1): void {
const x = a + c, y = b + d;
// ... вивести поточний дріб x/y на поточному рівні дерева
build(a, b, x, y, level + 1);
build(x, y, c, d, level + 1);
}
func build(a, b, c, d, level int) {
x, y := a+c, b+d
_ = x // ... вивести поточний дріб x/y на поточному рівні дерева
_ = y
build(a, b, x, y, level+1)
build(x, y, c, d, level+1)
}
// Виклик: build(0, 1, 1, 0, 1)
Алгоритм пошуку дробу
Алгоритм пошуку вже описано у доведенні того, що всі дроби з'являються в дереві, але ми повторимо його тут. Цей алгоритм є алгоритмом бінарного пошуку. Спочатку ми стоїмо в корені дерева і порівнюємо нашу ціль з поточним дробом. Якщо вони однакові, ми завершили роботу і зупиняємо процес. Якщо наша ціль менша, ми переходимо до лівого нащадка, інакше переходимо до правого нащадка.
Наївний пошук
Ось реалізація, яка повертає шлях до заданого дробу у вигляді послідовності символів 'L' і 'R', що означають перехід до лівого і правого нащадка відповідно. Ця послідовність символів однозначно визначає всі додатні дроби і називається системою числення Штерна–Броко.
- C++
- Python
- TypeScript
- Go
string find(int p, int q) {
int pL = 0, qL = 1;
int pR = 1, qR = 0;
int pM = 1, qM = 1;
string res;
while(pM != p || qM != q) {
if(p * qM < pM * q) {
res += 'L';
tie(pR, qR) = {pM, qM};
} else {
res += 'R';
tie(pL, qL) = {pM, qM};
}
tie(pM, qM) = pair{pL + pR, qL + qR};
}
return res;
}
def find(p, q):
pL, qL = 0, 1
pR, qR = 1, 0
pM, qM = 1, 1
res = []
while pM != p or qM != q:
if p * qM < pM * q:
res.append('L')
pR, qR = pM, qM
else:
res.append('R')
pL, qL = pM, qM
pM, qM = pL + pR, qL + qR
return "".join(res)
function find(p: number, q: number): string {
let pL = 0, qL = 1;
let pR = 1, qR = 0;
let pM = 1, qM = 1;
let res = "";
while (pM !== p || qM !== q) {
if (p * qM < pM * q) {
res += "L";
[pR, qR] = [pM, qM];
} else {
res += "R";
[pL, qL] = [pM, qM];
}
[pM, qM] = [pL + pR, qL + qR];
}
return res;
}
func find(p, q int) string {
pL, qL := 0, 1
pR, qR := 1, 0
pM, qM := 1, 1
var res []byte
for pM != p || qM != q {
if p*qM < pM*q {
res = append(res, 'L')
pR, qR = pM, qM
} else {
res = append(res, 'R')
pL, qL = pM, qM
}
pM, qM = pL+pR, qL+qR
}
return string(res)
}
Ірраціональним числам у системі числення Штерна–Броко відповідають нескінченні послідовності символів. Уздовж нескінченного шляху до ірраціонального числа алгоритм знаходитиме скоротні дроби з поступово зростаючими знаменниками, які дають дедалі кращі наближення ірраціонального числа. Тож, узявши префікс цієї нескінченної послідовності, можна досягти наближень із будь-якою бажаною точністю. Це застосування є важливим у годинникарстві, що й пояснює, чому дерево було відкрите саме в цій галузі.
Зауважте, що для дробу довжина отриманої послідовності може бути аж , наприклад, коли дріб має вигляд . Це означає, що наведений вище алгоритм не слід використовувати, якщо така складність не є прийнятною!
Логарифмічний пошук
На щастя, наведений вище алгоритм можна вдосконалити так, щоб гарантувати складність . Для цього зауважимо, що якщо поточними граничними дробами є і , то, зробивши кроків праворуч, ми переходимо до дробу , а зробивши кроків ліворуч, ми переходимо до дробу .
Тому замість того, щоб робити кроки L чи R по одному, ми можемо зробити кроків в одному й тому самому напрямку за раз, після чого перемкнемося на рух в інший напрямок, і так далі. У такий спосіб ми можемо знайти шлях до дробу як його кодування довжинами серій (run-length encoding).
Оскільки напрямки таким чином чергуються, ми завжди знатимемо, який з них узяти. Тож для зручності ми можемо представити шлях до дробу як послідовність дробів
таку, що і є межами інтервалу пошуку на -му кроці, починаючи з і . Тоді після -го кроку ми переходимо до дробу
де — додатне ціле число. Якщо ви знайомі з ланцюговими дробами, ви впізнаєте, що послідовність — це послідовність підхідних дробів числа , а послідовність представляє ланцюговий дріб числа .
Це дозволяє знайти кодування довжинами серій шляху до способом, який повторює алгоритм обчислення представлення дробу у вигляді ланцюгового дробу:
- C++
- Python
- TypeScript
- Go
auto find(int p, int q) {
bool right = true;
vector<pair<int, char>> res;
while(q) {
res.emplace_back(p / q, right ? 'R' : 'L');
tie(p, q) = pair{q, p % q};
right ^= 1;
}
res.back().first--;
return res;
}
def find(p, q):
right = True
res = [] # пари (довжина серії, напрямок)
while q:
res.append((p // q, 'R' if right else 'L'))
p, q = q, p % q
right = not right
# остання серія коротша на один крок
res[-1] = (res[-1][0] - 1, res[-1][1])
return res
function find(p: number, q: number): [number, string][] {
let right = true;
const res: [number, string][] = []; // пари [довжина серії, напрямок]
while (q) {
res.push([Math.floor(p / q), right ? "R" : "L"]);
[p, q] = [q, p % q];
right = !right;
}
// остання серія коротша на один крок
res[res.length - 1][0]--;
return res;
}
type run struct {
length int
dir byte
}
func find(p, q int) []run {
right := true
var res []run // пари (довжина серії, напрямок)
for q != 0 {
dir := byte('L')
if right {
dir = 'R'
}
res = append(res, run{p / q, dir})
p, q = q, p%q
right = !right
}
// остання серія коротша на один крок
res[len(res)-1].length--
return res
}
Утім, цей підхід працює лише тоді, коли ми вже знаємо і хочемо знайти його місце в дереві Штерна–Броко.
На практиці часто буває так, що заздалегідь невідомий, але ми можемо для конкретного перевірити, чи .
Знаючи це, ми можемо емулювати пошук на дереві Штерна–Броко, підтримуючи поточні межі і та знаходячи кожне за допомогою бінарного пошуку. Тоді алгоритм стає трохи технічнішим і потенційно має складність , якщо тільки формулювання задачі не дозволяє знайти швидше (наприклад, використовуючи floor від якогось відомого виразу).
Послідовність Фарея
Послідовність Фарея порядку — це відсортована послідовність дробів між і , знаменники яких не перевищують .
Ці послідовності названо на честь англійського геолога Джона Фарея, який у 1816 році висунув припущення, що будь-який дріб у послідовності Фарея є медіантою своїх сусідів. Це було доведено дещо пізніше Коші, але незалежно від обох математик Арос дійшов майже того самого висновку ще у 1802 році.
Послідовності Фарея мають багато цікавих властивостей самі по собі, але зв'язок із деревом Штерна–Броко є найочевиднішим. Насправді послідовності Фарея можна отримати, обрізаючи гілки дерева.
З алгоритму побудови дерева Штерна–Броко ми отримуємо алгоритм для послідовностей Фарея. Починаємо зі списку дробів . На кожній наступній ітерації вставляємо медіанту лише тоді, коли знаменник не перевищує . У певний момент список перестане змінюватися, і шукану послідовність Фарея буде знайдено.
Довжина послідовності Фарея
Послідовність Фарея порядку містить усі елементи послідовності Фарея порядку , а також усі нескоротні дроби зі знаменником , але останнє — це просто функція Ейлера . Тож довжина послідовності Фарея порядку дорівнює
або, що еквівалентно, розгорнувши рекурсію, ми отримуємо