Wieże Hanoi – zrozumienie algorytmu i/lub rekurencji (JavaScript, faktyczne przenoszenie)

0

Ostatnio próbuję zrozumieć, jak działa algorytm rekurencyjny w łamigłówce Wieże Hanoi. (Z czasem widzę pewien postęp).

Przeczytałem kilka wątków na Stack Overflow oraz kilka innych stron, w tym Wikipedię polską i angielską. (Z napotkanych algorytmów poza rekurencyjnym oraz z dowodów matematycznych niewiele zrozumiałem, przyznaję). Obejrzałem dwa wideo (uznałem, że oglądanie więcej będzie stratą czasu z uwagi na nikłe szanse odmienności materiału). Przejrzałem wszystkie kody na Wikipedii polskiej/angielskiej oraz kilka na Rosetta Code. (Na Rosetta Code jest, jak wynika ze spisu treści, 150 implementacji. Do tego nazwy wielu języków, zdaje się, pierwszy raz widziałem. Przeglądanie więc wszystkich implementacji tam uznałem za stratę czasu).

Ponieważ samo czytanie/słuchanie/oglądanie nie przyczyniło się do całkowitego zrozumienia (choć, jak napisałem, widzę postęp), zabrałem się za implementowanie samemu. Napisałem trzy implementacje w JavaScripcie (w tym jedną działającą zgodnie z opisami). (Liczę, że obecnie mam dwie, bo drugą upodobniłem do pierwszej). Przy czym: chyba żadna implementacja, jaką widziałem, nic nie przenosi faktycznie (wypisuje jedynie); mnie natomiast wydaje się, że najłatwiej będzie mi to zrozumieć, gdy program będzie faktycznie coś przenosił. Tak więc wszystkie moje implementacje przenoszą liczby.

Nadal jednak mam wrażenie, że nie do końca wszystko rozumiem.

Do tej pory udało mi się wyróżnić dwa kluczowe aspekty zrozumienia: jak działa sam algorytm oraz jak działa sama rekurencja (w sensie wywołań programu). Algorytm wydaje mi się oczywisty, a złożony zarazem. Nie wiem więc, czy go rozumiem, czy nie. Przez większość chwil mam wrażenie, że go rozumiem. Rekurencja wydaje mi się prosta, niemniej nieoczywista. Przez większość chwil mam wrażenie, że coś mi umyka.

Pytanie więc moje: pomoże ktoś zrozumieć?

Nie wiem, czy łatwiej będzie komuś wytłumaczyć mi to w teorii czy w praktyce, dlatego w razie czego załączam obecne dwie własne implementacje: działającą oraz niedziałającą. Docelowo chciałbym zarówno zrozumieć algorytm oraz rekurencję, jak i uczynić niedziałającą implementację działającą. Co do implementacji niedziałającej, stawiam jej dwa wymagania: (1) niekorzystanie z zewnętrznego stanu (czyli przeciwnie do implementacji działającej); (2) korzystanie z rekurencji ogonowej (także przeciwnie do implementacji działającej). (Myślę, że z punktem nr 2 nie będę mieć problemu. Z punktem nr 1 nie mogę sobie na razie poradzić. Mam nadzieję, że albo zrozumienie samego algorytmu, albo samej rekurencji, albo ostatecznie obu naraz przyczyni się do znalezienia przeze mnie rozwiązania).

Implementacja działająca:

// JavaScript

exports.toh3
	= (n, stk, tmp, res) => {
		console.debug(
			`${stk.name}: [${stk.value}], `
			+ `${tmp.name}: [${tmp.value}], `
			+ `${res.name}: [${res.value}]`
		);

		if (n > 0) {
			// The purpose of this invokation seems to be to tell from which peg
			//	to move to which WHEN UNWINDING THE STACK
			exports.toh3(n - 1, stk, res, tmp);

			console.debug(`Moving ${n} from ${stk.name} to ${res.name}`);
			res.value.unshift(
				stk.value.shift()
			);

			exports.toh3(n - 1, tmp, stk, res);
		}
	};

// Test

const globalStateToh3 = [
	new Peg("stk", [1, 2, 3]),
	new Peg("tmp", []),
	new Peg("res", [])
];

exports.toh3(3, globalStateToh3[0], globalStateToh3[1], globalStateToh3[2]);

console.debug();
console.debug("stk === ", globalStateToh3[0]);
console.debug("tmp === ", globalStateToh3[1]);
console.debug("res === ", globalStateToh3[2]);

Implementacja NIEdziałająca:

// JavaScript

const Peg
	= class {
		/**
		 * @param {string} name
		 * @param {number[]} value
		 */
		constructor(name, value) {
			this.name = name;
			this.value = value;
		}
	};

const State
	= class {
		/**
		 * @param {Peg} stk
		 * @param {Peg} tmp
		 * @param {Peg} res
		 */
		constructor(stk, tmp, res) {
			this.stk = stk;
			this.tmp = tmp;
			this.res = res;
		}
	};

exports.toh2
	= (n, state) => {
		console.debug(
			`${state.stk.name}: [${state.stk.value}], `
			+ `${state.tmp.name}: [${state.tmp.value}], `
			+ `${state.res.name}: [${state.res.value}]`
		);

		if (n === 1) {
			console.debug(
				`Moving ${n} from ${state.stk.name} to ${state.res.name}`
			);

			return new State(
				new Peg("stk", state.stk.value.slice(1)),
				state.tmp,
				new Peg("res", [state.stk.value[0]].concat(state.res.value))
			);
		} else {
			let newState
				= exports.toh2(
					n - 1, new State(state.stk, state.res, state.tmp)
				);

			newState = exports.toh2(
				1, new State(newState.stk, newState.tmp, newState.res)
			);

			newState = exports.toh2(
				n - 1, new State(newState.tmp, newState.stk, newState.res)
			);

			return newState;
		}
	};

// Test

const resultSt
	= exports.toh2(
		3,
		new State(
			new Peg("stk", [1, 2, 3]),
			new Peg("tmp", []),
			new Peg("res", [])
		)
	);

console.debug();
console.debug("stk === ", resultSt.stk);
console.debug("tmp === ", resultSt.tmp);
console.debug("res === ", resultSt.res);
0

Aktualizacja w moim rozumieniu: dochodzę do wniosku, że by zrozumieć, potrzebuję zunifikować dwa fakty: 1) logicznie, słownie, "in plain English", zaczynamy od przenoszenia; 2) faktycznie, rekurencyjnie, komputerowo, nie zaczynamy od przenoszenia, a od zamiany miejscami słupków. Zdaję sobie sprawę, że ta zamiana miejscami jest kluczowa, ale nadal nie "czuję", dlaczego tak jest.

PS. A samo przenoszenie wykonywane jest w górę w dół (?) stosu... O, i to jest ta dychotomia; przecież nie mamy żadnego stosu, jak o tym mówimy. To jak to jest? Jak połączyć istnienie stosu w komputerze i jego nieistnienie w opisie słownym tego algorytmu? I mówimy przecież: "należy przenieść n-1 krążków", a nie: "należy słupki zamienić miejscami". Jak połączyć zamianę miejscami z niezamienianiem niczego?

0

Aktualizacja: mam wrażenie, że problem z programem – czyli nie mówię tu o moim rozumieniu algorytmu czy rekurencji – leży w tym, że algorytm wymaga oddzielenia dwóch konceptów (tj. oddzielnego nimi zarządzania): zawartości danego słupka (tj. nie argumentu funkcji, tylko słupka, konceptu jak gdyby "globalnego" w stosunku do poszczególnych rekurencyjnych wywołań funkcji) oraz pozycji słupka (czyli tego, który słupek funkcja teraz dane wywołanie funkcji traktuje jako "źródłowy", a który jako "docelowy" – na co na które to traktowanie wpływa zmiana kolejności argumentów w wywołaniach rekurencyjnych w stosunku do ich kolejności w wywołaniu funkcji wywołującej).

I teraz ja to widzę tak (pewnie źle): gdy stan jest globalny (np. referencje, jak normalnie w JavaScripcie), zarządzanie jednym i drugim przychodzi niejako samo (?) (i dlatego pierwsza implementacja działa). Gdy natomiast stan jest lokalny, zarządzanie jednym i drugim wymaga więcej finezji (?). I tej finezji właśnie jeszcze uzyskać nie potrafię, i dlatego druga moja implementacja nie działa. Znaczy, dokładnie, szukam struktury programu będącej odpowiednio finezyjną.

0

Jako poparcie powyższej tezy załączam przykładowe "wywołanie robocze", jakie przykładową definicję funkcji, jaką sobie napisałem w notatkach (pseudokod) (kod trochę zmodyfikowany, by miał sens pod powyższą teorię):

Pseudokod

toh4 (state of (first, tmp, last))
    new state <- toh3(new state of (first, last, tmp)) // Swap tmp and last
    new state <- toh1(state from toh4) // Do not swap anything
    new state <- toh3(new state of (tmp, first, last)) // Swap first and tmp
    return new state

Według mnie z tego widać, że jakkolwiek stan słupków się zmienia po każdym wywołaniu, to ich kolejność jest zmieniana nie względem kolejnych wywołań, a względem wywołania wywołującego (tj. tutaj: funkcji toh4).


UPDATE: Zmieniłem – nie jest to przecież wywołanie (w notatkach było, no), ale definicja funkcji. Tak czy siak, sens jest ten sam.


UPDATE4: Zmieniłem słowo "current" na "new" w instrukcji return current state. Myślę, że "current" było mylące.

4

Hanoi jest nie tylko dla mnie nieintuicyjne bo nie jesteśmy przyzwyczajeni do "trybu myślenia możnowładców"

*Przestaw największy krążek, a z całą resztą mniej znaczącej pracy poradzą sobie podwładni. Ty tylko zadysponujesz mniej wymagające zadanie i poczekasz na efekt.
*

Przykład wież Hanoi, ale prostszy, szukanie max w nieposortowanej tablicy
Zaczynasz od ostatniego elementu arr[arr.length-1]
"Zlecasz podwładnym" poszukanie max dla pozostałych elementów tablicy. Dostajesz od nich wynik, który porównujesz ze swoim elementem arr[arr.length-1]
Gotowe.
Hanoi to ten sam sposób podejścia "od ogona strony" dlatego nieintuicyjny. Normalny ;) człowiek szuka kolejno od arr[0] do ostatniego elementu i porównuje czy ma aktualnie 'maxSoFar'
W Hanoi w sposób tradycyjny - tak jak jesteśmy przyzwyczajeni - kombinujemy jakby tu zabrać najmniejszy krążek, potem myślimy co dalej z tym prawie najmniejszym... i się gubimy.

Rekurencja w wieżach Hanoi i poniższym przykładzie, to nic innego jak delegowanie zadań podwładnym. I to jest dla programisty nieintuicyjne.
A programista chce zrobić wszystko sam od początku do końca - to dla programisty naturalne :)

function max(arr) {

    function recMax(arr, idx) {
        if (idx === 0) {
            return arr[idx];
        }

        const valueAtCurrentIdx = arr[idx]
        const maxFromRemainingPartOfArray = recMax(arr, idx - 1);

        return valueAtCurrentIdx > maxFromRemainingPartOfArray ? valueAtCurrentIdx : maxFromRemainingPartOfArray;
    }

    return recMax(arr, arr.length - 1);
}

Algorytm wież Hanoi wygląda mniej więcej tak:
Wszystkie krążki w hierarchii na lewej wieży

Największy krążek - Pan Prezes - zleca: Wszyscy moi podwładni przenoszą się na środkową wieżę (rekurencja dla n-1) - nie obchodzi mnie jak to zrobicie, to wasz problem, ma być zrobione

Pan Prezes bez tłoku, solo, ma wolną drogę (nie ma żadnego blokującego krążka nad sobą bo wszyscy już siedzą na środkowej wieży), przenosi się na pustą prawą wieżę

Z prawej wieży Pan Prezes wydaje polecenie: Wszyscy moi podwładni przenoszą się ze środkowej wieży na moją wieżę (prawą) - wywołanie rekurencyjne dla n-1 krążków. I Pan Prezes mówi - nie obchodzi mnie jak to zrobicie, to wasz problem, ma być zrobione

W zasadzie to koniec algorytmu

Warunek stopu: tylko jeden krążek do przeniesienia. Nie ma podwładnych, sam [robisz czarną robotę] ... od jednej wieży do drugiej wieży
Wywołanie rekurencyjne: z pozostałych krążków największy przejmuje rolę Pana Prezesa i leci z algorytmem. (prawa wieża będzie lewą, lewa prawą)

Sam algorytm to nic nadzwyczajnego, jest bardzo życiowy ;)

0

@BraVolt: To ma duży sens.

Teraz: jak połączyć to z wywołaniami rekurencyjnymi?

Bo przecież komputer nie stawia się w roli "prezesa", tylko sam robi wszystko. No dobrze, można powiedzieć, to ja jestem "prezesem", więc stawiam się jakby obok komputera i patrzę, jak komputer – czyli wszyscy ci "podwładni" w jednym – robi wszystko. Hm.

Dziś doszedłem do przekonania (przed otwarciem forum), że należy spojrzeć na to stawiając się, jak to Ty napisałeś, raz tu, raz tu. Nie można "siedzieć na pierwszym słupku i wydawać polecenia". Trzeba przenieść się bodaj na trzeci słupek, i z perspektywy tego trzeciego dopiero zlecać. Jak gdyby sytuacja była odwrócona.

I te "odwrócenia" mnie najbardziej denerwują. Są tak nieintuicyjne dla mnie, a tak intuicyjne w kontekście zapisu w pseudokodzie / w języku programowania. Przecież nie stworzę 4 różnych funkcji dla 4 krążków (np. toh1, toh2, toh3 i toh4). W ten sposób ta sama funkcja raz patrzy z pozycji słupka nr 1, a raz z pozycji słupka nr 3. Jak to jest, że zapis w języku programowania tak pasuje do tego problemu, a jednocześnie jest tak nieintuicyjny z punktu widzenia człowieka.

Ale dobra. Pomyślę nad Twoją analogią jeszcze dziś, może to rozwiążę. :)


UPDATE: Dla lepszego zilustrowania tych "odwróceń" przygotowałem sobie małą pomoc. Wklejam ją tu, może się przyda w dyskusji. Moim zdaniem tutaj dobrze widać, jak algorytm z każdym nowym wywołaniem rekurencyjnym musi odwrócić "swoją sytuację".

4	Move 3 disks from A to B
	Move 1 disk from A to C
	Move 3 disks from B to C

3	Move 2 disks from A to C
	Move 1 disk from from A to B
	Move 2 disks from C to B

2	Move 1 disk from A to B
	Move 1 disk from A to C
	Move 1 disk from B to C

1	Move 1 disk from A to C
1
Silv napisał(a):

Dziś doszedłem do przekonania (przed otwarciem forum), że należy spojrzeć na to stawiając się, jak to Ty napisałeś, raz tu, raz tu. Nie można "siedzieć na pierwszym słupku i wydawać polecenia". Trzeba przenieść się bodaj na trzeci słupek, i z perspektywy tego trzeciego dopiero zlecać. Jak gdyby sytuacja była odwrócona.
I te "odwrócenia" mnie najbardziej denerwują. Są tak nieintuicyjne dla mnie

Dobrze myślisz, brakuje ci tylko kropki nad i.
Zaczynasz pierwsze wywołanie
wszyscy mniejsi od prezesa biegiem na środkową wieżę
przenieśli się (rekurencyjnym wywołaniem)
prezes przechodzi na z lewej (został tu teraz tylko on) na wieżę prawą (była pusta)
prezes wydaje polecenie, wszyscy ze środkowej wieży mają się przemieścić na prawą, wszyscy są mniejsi, więc mogą tu tworzyć mniejszą wieżę ze mną jako największym - jestem podstawą (rekurencyjne wywołanie n-1)

Z punktu widzenia wywołania rekurencyjnego n-1 to "zastępca prezesa" dostaje w wywołaniu która wieża ma być jego lewą, która buforową-środkową, która docelową prawą.
Prezes zna swojego 'zastępcę' więc wie, z którym krążkiem ma wywołać rekurencyjnie hanoiRec(master, left, middle, rirght)

Cały bajer, to nie pomylić się w wywoływaniu dla 'zastępcy i która wieża dla niego ma być początkową a która końcową)

Kiedy piszesz, to napisz i uruchom dla jednego krążka - warunek stopu, przypadek trywialny oby tylko o nim nie zapomnieć.
Dla dwóch krążków, wtedy większy jest prezesem, mniejszy całą resztą firmy. Proste
Trzech krążków - tu już wywołasz ciekawie, bo dwa krążki tworzą firmę
I w tym miejscu (albo dla 4 krążków) możesz się gubić: otóż, nikt nie jest tłustszym kotem od prezesa. Nawet jak pod spodem są krążki ułożone rosnąco, to i tak są one większe od tych aktualnie przenoszonych, więc jakby ich nie było z puntu widzenia przenoszenie mniejszych - wszystkie mniejsze mają do dyspozycji "czyste 3 wieże" (z punktu widzenia wywołanego rekurencyjnego przenoszenia mniejszych krążków)

Kiedy już debuggerem albo console.log sprawdzisz dla 3, 4, to musisz uwierzyć albo zdać się na dowodzenie przez indukcję matematyczną. Przeniosło n krążków, to jest prawdziwe dla n+1 krążków

Nie próbuj za daleko iść z analizą krok po kroku, bo klątwa mówi, że kiedy ktoś (mnisi w świątyni) zrobią to w końcu w ten sposób to nastąpi koniec świata.

Masz się przestawić w myśleniu na sposób 'delegowania reszty roboty dla n-1 przypadków' a ty robisz tylko swoje ze swoim jednym krążkiem.

EDIT
krążek D najmniejszy, A największy

wywołanie hanoiRec(A)
D
C
B
A

Pytanie. Jak przenieść stosik
D
C
B

Wywołując hanoiRec(B)
Co z tego, że A leży głęboko na spodzie, z punktu widzenia tego wywołania wieża ma tylko 3 krążki
D
C
B

No to jest do przeniesienia stosik
D
C
więc wywołujemy hanoiRec(C)
i nie przejmujemy się, że głęboko pod spodem siedzą
B
A
bo są one 'out of scope' - dla tego wywołania są tylko 2 krążki

Zostanie jeden krążek i tu się trzeba skupić, że by nie poleciał przez zapomnienie Stack Overflow ;)

I jeszcze jedno, wieże są np. X, Y, Z
dla największego krążka A, lewa to X, środkowa Y, prawa Z
Dla wywołania n-1 B, lewa to X, środkowa Z, prawa Y
I tak dalej, trzeba się skupić, żeby tu się nie pomylić w przydzielaniu wież

Ja się gdzieś rąbnąłem, to sorry, piszę z głowy, nie z listingu.

0

przenieśli się (rekurencyjnym wywołaniem)

Ech, ta magia rekurencji. :/

Nawet jak pod spodem są krążki ułożone rosnąco, to i tak są one większe od tych aktualnie przenoszonych, więc jakby ich nie było z puntu widzenia przenoszenie mniejszych - wszystkie mniejsze mają do dyspozycji "czyste 3 wieże" (z punktu widzenia wywołanego rekurencyjnego przenoszenia mniejszych krążków)

Możesz mi wierzyć lub nie, ale przed paroma minutami to samo pomyślałem!

A w przerwach myślenia próbuję debugować program :)


PS. To dla odpoczynku: obecny stan mojej niedziałającej – wciąż – implementacji:

// JavaScript

const Peg
	= class {
		constructor(name, value) {
			this.name = name;
			this.value = value;
		}

		static fromPeg(peg) {
			return new Peg(peg.name, peg.value);
		}
	};

exports.toh2
	= (n, state) => {
		console.debug(
			`${state.stk.name}: [${state.stk.value}], `
			+ `${state.tmp.name}: [${state.tmp.value}], `
			+ `${state.res.name}: [${state.res.value}]`
		);

		if (n === 1) {
			console.debug(
				`Moving ${n} from ${state.stk.name} to ${state.res.name}`
			);

			return new State(
				new Peg(state.stk.name, state.stk.value.slice(1)),
				Peg.fromPeg(state.tmp),
				new Peg(
					state.res.name, [state.stk.value[0]].concat(state.res.value)
				)
			);
		} else {
			let newState
				= exports.toh2(
					n - 1,
					new State(
						Peg.fromPeg(state.stk),
						Peg.fromPeg(state.res),
						Peg.fromPeg(state.tmp)
					)
				);

			// Basically, here the problem seems to be:
			//	give me the new CONTENTS of the pegs, NOT their new order
			// Manipulating references seems to clearly support separating
			//	these requests; on the other hand, manipulating copies seems
			//	not...
			// In other words: while newState now contains pegs (i.e., copies
			//	of them) with their new content, the order of them also changed
			//	(according to the invoking invokation of the function);
			//	we need their order within the invoking invokation
			//	of the function, not after the last recursive invokation
			//	returned...
			newState
				= exports.toh2(
					1,
					new State(
						new Peg(state.stk.name, newState.stk.value),
						new Peg(state.tmp.name, newState.tmp.value),
						new Peg(state.res.name, newState.res.value)
					)
				);

			newState
				= exports.toh2(
					n - 1,
					new State(
						new Peg(state.tmp.name, newState.tmp.value),
						new Peg(state.stk.name, newState.stk.value),
						new Peg(state.res.name, newState.res.value)
					)
				);

			return newState;
		}
	};
1

Na początek może spróbuj sobie rekurencyjnie odwrócić listę, to fajnie pokazuje inne spojrzenie na problem. Inny sposób, żeby się z tym oswoić - spróbuj rozpisać sobie drzewo wywołań Twojej funkcji dla np. 4 krążków

0

Aktualizacja: implementacja wcześniej niedziałająca wydaje się działać! Posprzątam rozwiązanie i wkleję.


UPDATE:

Wklejam. Poniższa implementacja według tego, jak ją oceniam, spełnia założenie nr 1 (nie korzysta z zewnętrznego stanu). Nie spełnia jeszcze założenia nr 2, czyli: rekurencja nie jest ogonowa. Będę próbować przerobić. A samo rozwiązanie… nie satysfakcjonuje mnie, niezależnie od spełniania tych dwóch założeń. Mało intuicyjna jest ta funkcja pomocnicza find (nazwa na pewno do zmiany z uwagi przede wszystkim na ambiguity z Array.prototype.find). I – dla mnie nieituicyjne jest przechodzenie od razu do przenoszenia dysków; to znaczy, brak oddzielnej ścieżki dla n === 1.

const Peg
	= class {
		/**
		 * @param {string} name
		 * @param {number[]} disksArr
		 */
		constructor(name, disksArr) {
			this.name = name;
			this.disksArr = disksArr;
		}

		/**
		 * @param {Peg} peg
		 * @returns {Peg}
		 */
		static fromPeg(peg) {
			return new Peg(peg.name, peg.disksArr);
		}
	};

const State
	= class {
		/**
		 * @param {Peg} stk
		 * @param {Peg} tmp
		 * @param {Peg} res
		 */
		constructor(stk, tmp, res) {
			this.stk = Peg.fromPeg(stk);
			this.tmp = Peg.fromPeg(tmp);
			this.res = Peg.fromPeg(res);
		}

		/**
		 * @param {State} state
		 * @returns {State}
		 */
		static fromState(state) {
			return new State(state.stk, state.tmp, state.res);
		}
	};

/**
 * @param {State} st
 * @param {string} pegName
 * @returns {Peg}
 */
const find
	= (st, pegName) =>
		st.stk.name === pegName
			? Peg.fromPeg(st.stk)
			: st.tmp.name === pegName
				? Peg.fromPeg(st.tmp)
				: Peg.fromPeg(st.res);

/**
 * @param {number} n
 * @param {State} state
 * @returns {State}
 */
exports.toh2
	= (n, state) => {
		console.debug(
			`${state.stk.name}: [${state.stk.disksArr}], `
			+ `${state.tmp.name}: [${state.tmp.disksArr}], `
			+ `${state.res.name}: [${state.res.disksArr}]`
		);

		if (n === 0) {
			return State.fromState(state);
		} else {
			let newState
				= exports.toh2(
					n - 1,
					new State(
						Peg.fromPeg(state.stk),
						Peg.fromPeg(state.res),
						Peg.fromPeg(state.tmp)
					)
				);

			newState
				= new State(
					Peg.fromPeg(find(newState, state.stk.name)),
					Peg.fromPeg(find(newState, state.tmp.name)),
					Peg.fromPeg(find(newState, state.res.name))
				);

			console.debug(
				`Moving ${n} from ${newState.stk.name} to ${newState.res.name}`
			);

			newState
				= new State(
					new Peg(newState.stk.name, newState.stk.disksArr.slice(1)),
					Peg.fromPeg(newState.tmp),
					new Peg(
						newState.res.name,
						[newState.stk.disksArr[0]].concat(newState.res.disksArr)
					)
				);

			newState
				= exports.toh2(
					n - 1,
					new State(
						Peg.fromPeg(newState.tmp),
						Peg.fromPeg(newState.stk),
						Peg.fromPeg(newState.res)
					)
				);

			newState
				= new State(
					Peg.fromPeg(find(newState, state.stk.name)),
					Peg.fromPeg(find(newState, state.tmp.name)),
					Peg.fromPeg(find(newState, state.res.name))
				);

			return newState;
		}
	};

const resultSt
	= exports.toh2(
		3,
		new State(
			new Peg("stk", [1, 2, 3]),
			new Peg("tmp", []),
			new Peg("res", [])
		)
	);

console.debug();
console.debug(resultSt.stk);
console.debug(resultSt.tmp);
console.debug(resultSt.res);

PS: Powyższa implementacja wypisuje, powiedziałbym, tę samą, hm, "treść", co implementacja działająca z pierwszego postu; jedynie formatowanie wyniku jest inne (nie jestem pewien, czy formatowanie w implementacji z pierwszego postu było poprawne, dlatego zmieniłem na takie, co do którego jestem "bardziej pewien").

0

Aktualizacja: nie widzę sposobu, by przekuć rekurencję w ogonową. Może ktoś widzi jakąś możliwość? Najnowszy kod programu załączam poniżej.

// JavaScript

const Peg
	= class {
		/**
		 * @param {string} name
		 * @param {number[]} disksArr
		 */
		constructor(name, disksArr) {
			this.name = name;
			this.disksArr = disksArr;
		}
	};

/**
 * @param {Peg} peg
 * @returns {Peg}
 */
const getPegCopy = peg => new Peg(peg.name, peg.disksArr.slice());

const State
	= class {
		/**
		 * @param {number} n
		 * @param {number} movesCount
		 * @param {Peg} stk
		 * @param {Peg} tmp
		 * @param {Peg} res
		 */
		constructor(n, movesCount, stk, tmp, res) {
			this.n = n;
			this.movesCount = movesCount;
			this.stk = getPegCopy(stk);
			this.tmp = getPegCopy(tmp);
			this.res = getPegCopy(res);
		}
	};

/**
 * @param {State} state
 * @returns {State}
 */
const getStateCopy
	= state =>
		new State(state.n, state.movesCount, state.stk, state.tmp, state.res);

/**
 * @param {State} state
 * @param {string} pegName
 * @returns {Peg}
 */
const findPeg
	= (state, pegName) =>
		state.stk.name === pegName
			? getPegCopy(state.stk)
			: state.tmp.name === pegName
				? getPegCopy(state.tmp)
				: getPegCopy(state.res);

/**
 * @param {State} state
 * @param {State} referenceState
 * @returns {State}
 */
const reorderPegs
	= (state, referenceState) =>
		new State(
			state.n,
			state.movesCount,
			getPegCopy(findPeg(state, referenceState.stk.name)),
			getPegCopy(findPeg(state, referenceState.tmp.name)),
			getPegCopy(findPeg(state, referenceState.res.name))
		);

/**
 * @param {State} state
 * @returns {State}
 */
exports.toh2
	= state => {
		console.debug(
			`${state.stk.name}: [${state.stk.disksArr}], `
			+ `${state.tmp.name}: [${state.tmp.disksArr}], `
			+ `${state.res.name}: [${state.res.disksArr}]`
		);

		if (state.n === 0) {
			return getStateCopy(state);
		} else if (state.n === 1) {
			console.debug(
				`Moving a disk from ${state.stk.name} to ${state.res.name}`
			);

			return new State(
				state.n,
				state.movesCount + 1,
				new Peg(state.stk.name, state.stk.disksArr.slice(1)),
				getPegCopy(state.tmp),
				new Peg(
					state.res.name,
					[state.stk.disksArr[0]].concat(state.res.disksArr)
				)
			);
		} else {
			let newState
				= reorderPegs(
					exports.toh2(
						new State(
							state.n - 1,
							state.movesCount,
							getPegCopy(state.stk),
							getPegCopy(state.res),
							getPegCopy(state.tmp)
						)
					),
					state
				);

			newState
				= exports.toh2(
					new State(
						1,
						newState.movesCount,
						getPegCopy(newState.stk),
						getPegCopy(newState.tmp),
						getPegCopy(newState.res)
					)
				);

			newState
				= reorderPegs(
					exports.toh2(
						new State(
							state.n - 1,
							newState.movesCount,
							getPegCopy(newState.tmp),
							getPegCopy(newState.stk),
							getPegCopy(newState.res)
						)
					),
					state
				);

			return newState;
		}
	};

const mockArr = [1, 2, 3];

const resultSt
	= exports.toh2(
		new State(
			mockArr.length,
			0,
			new Peg("stk", mockArr),
			new Peg("tmp", []),
			new Peg("res", [])
		)
	);

console.debug();
console.debug(resultSt.stk);
console.debug(resultSt.tmp);
console.debug(resultSt.res);
console.debug(`Moves count: ${resultSt.movesCount}`);
0

Odchodząc na moment od tematu programu: w 1883 – który to rok jest podawany jako rok opisu tej łamigłówki – nie było takich komputerów, jak je rozumiemy dzisiaj (karty perforowane już były, mówiąc mimochodem). Jak zatem może rekurencyjne rozwiązanie tej łamigłówki tak dobrze pasować do opisu w języku programowania?

1

Dawno, dawno temu napisałem apkę jako demonstrację problemu dla dzieci, sam rekurencyjny algo hanoi zajmuje 13 linii: https://github.com/marcin-chwedczuk/hanoi/blob/master/app/scripts/hanoi.js

A teraz do faktycznej zagadki. Wbrew pozorom wieże Hanoi posiadają bardzo proste iteracyjne rozwiązanie i to na znalezieniu tego rozwiązania zagdka polega. Nikt w XIX wieku nie robił tego rekurencją.
TIP: Obserwuj ruchy najmniejszego pierścienia. Są dwa rozwiązania dla parzystych i nieparzystych n. Jak już dojdziesz do rozwiązania to będziesz się śmiał z tych wszystkich "rekurencyjnych" implementacji.

Co do "tail-recursion" - pownieważ masz podwójną rekrurencję to nie da się tego przerobić na tail-call. Możesz przerobić tylko jedną gałąź rekrurencji. Czyli mniej wiecej tak:

hanoi(args) {
  while (true) {
    hanoi(...)

    // przeniesienie pierscienia
    // + warunek stopu - zamiast return bo n == 0 to break
    args = newArgs; // 2 call rekurencji zastapiony przez podstawienie argumentow
  }
}

Pozbycie się rekurencji całkowite bedzie wymagać symulacji stosu wywołań zwykłym stosem. Ale jak już mówiłem jest piękne rozwiązanie iteracyjne.

EDIT: Wow, napisałem na ten temat nawet bloga: https://marcin-chwedczuk.github.io/iterative-solution-to-towers-of-hanoi-problem

2
Silv napisał(a):

Odchodząc na moment od tematu programu: w 1883 – który to rok jest podawany jako rok opisu tej łamigłówki – nie było takich komputerów

Jakoś dawali radę. Jak im się wtedy udawało, to przechodzili do historii. W 20 i 21 wieku mogliby co najwyżej zaliczenie kartkówki dostać.

Fibonacci, 12/13 wiek
https://en.wikipedia.org/wiki/Fibonacci
https://en.wikipedia.org/wiki/Fibonacci_number

Później golden ratio https://en.wikipedia.org/wiki/Golden_ratio
znane z 4/3 w. p.n.e
Euklides https://en.wikipedia.org/wiki/Euclid
policzone 16/17 w. https://en.wikipedia.org/wiki/Michael_Maestlin

Klasycznie, rekurencyjnie, Fibonacci numbers

function fib(n) {

    if (n < 2) {
        return n;
    }

    return fib(n - 1) + fib(n - 2);
}

Można policzyć iteracyjnie

function fib(n) {

    let b = 0;
    let a = 1;


    if (n === b) {
        return b;
    }

    let c = n - 1;
    while (c > 0) {
        const temp = a + b;
        b = a;
        a = temp;
        c--;
    }

    return a;
}

Można memoization&dynamic programming
https://en.wikipedia.org/wiki/Memoization

const cache = []
cache[0] = 0
cache[1] = 1

function fib(n) {

    if (n === 0) {
        return 0;
    }

    if (cache[n]) {
        return cache[n];
    }

    const fibn = fib(n - 1) + fib(n - 2);
    cache[n] = fibn;

    return fibn;
}

Można step-up approach i zbudować z dołu do góry całe rozwiązanie, z pośrednimi krokami
https://en.wikipedia.org/wiki/Top-down_and_bottom-up_design

function fib(n) {

    const fibonacci = [0, 1];
    if (n < 2) {
        return fibonacci[n];
    }

    for (let i = 2; i <= n; i++) {
        fibonacci[i] = fibonacci[i - 1] + fibonacci[i - 2];
    }

    return fibonacci[n];
}

Tak się zaczęły mnożyć te króliki mistrza Fibonacciego na laptopie, że sobie prosty test zrobiłem
Biblioteka/framework JEST https://jestjs.io/

module.exports = fib
const fib = require('./fibon');


describe('Fibonacci related tests', () => {

    test('First 10 Fibonacci numbers'', () => {
        expect(fib(0)).toEqual(0)
        expect(fib(1)).toEqual(1)
        expect(fib(2)).toEqual(1)
        expect(fib(3)).toEqual(2)
        expect(fib(4)).toEqual(3)
        expect(fib(5)).toEqual(5)
        expect(fib(6)).toEqual(8)
        expect(fib(7)).toEqual(13)
        expect(fib(8)).toEqual(21)
        expect(fib(9)).toEqual(34)
        expect(fib(10)).toEqual(55)
    })
})

Do nauki najlepiej brać proste problemy i na nich zrozumieć i poćwiczyć
Może być nawet odpowiednik 'Hello World' czyli n! na 4 różne sposoby. :)

0
0xmarcin napisał(a):

A teraz do faktycznej zagadki. Wbrew pozorom wieże Hanoi posiadają bardzo proste iteracyjne rozwiązanie i to na znalezieniu tego rozwiązania zagdka polega. Nikt w XIX wieku nie robił tego rekurencją.

Hm. Skoro tak mówisz.

Co do "tail-recursion" - pownieważ masz podwójną rekrurencję to nie da się tego przerobić na tail-call. Możesz przerobić tylko jedną gałąź rekrurencji. Czyli mniej wiecej tak:

hanoi(args) {
  while (true) {
    hanoi(...)

    // przeniesienie pierscienia
    // + warunek stopu - zamiast return bo n == 0 to break
    args = newArgs; // 2 call rekurencji zastapiony przez podstawienie argumentow
  }
}

Dzięki. Jest tu pętla, więc nie wykorzystam u siebie, ale zawsze warto poznać nowe punkty widzenia. :)

Pozbycie się rekurencji całkowite bedzie wymagać symulacji stosu wywołań zwykłym stosem. Ale jak już mówiłem jest piękne rozwiązanie iteracyjne.

Ciekawe, nie myślałem, że aż trzeba by cały stos wywołań symulować. Nie wiem zresztą wiele na temat takich technik. Czy jakoś z tym koresponduje continuation passing?

EDIT: Wow, napisałem na ten temat nawet bloga: https://marcin-chwedczuk.github.io/iterative-solution-to-towers-of-hanoi-problem

Ciekawe z tym umiejscowieniem słupków w trójkąt (w animacji)! Nie myślałem o tym tak. To ma duży sens, skoro jest zawijanie.

BraVolt napisał(a):
Silv napisał(a):

Odchodząc na moment od tematu programu: w 1883 – który to rok jest podawany jako rok opisu tej łamigłówki – nie było takich komputerów

Jakoś dawali radę. Jak im się wtedy udawało, to przechodzili do historii. W 20 i 21 wieku mogliby co najwyżej zaliczenie kartkówki dostać.

Pewnie by dostali inne algorytmy do wymyślenia, bo przecież nie te, które już sami wymyślili. ;)

Można memoization&dynamic programming
https://en.wikipedia.org/wiki/Memoization

O, to dobre.

Można step-up approach i zbudować z dołu do góry całe rozwiązanie, z pośrednimi krokami
https://en.wikipedia.org/wiki/Top-down_and_bottom-up_design

O tym muszę poczytać.

Może być nawet odpowiednik 'Hello World' czyli n! na 4 różne sposoby. :)

Cóż, mnie ciężko kilka razy ten sam problem męczyć.

1 użytkowników online, w tym zalogowanych: 0, gości: 1