diff --git a/lib/solvers/OptimalVcycleMultigridSolver.m b/lib/solvers/OptimalVcycleMultigridSolver.m
new file mode 100644
index 0000000000000000000000000000000000000000..9c3cc0baa13945cc83fef787961aa38e967c5358
--- /dev/null
+++ b/lib/solvers/OptimalVcycleMultigridSolver.m
@@ -0,0 +1,137 @@
+% OptimalVcycleMultigridSolver (subclass of IterativeSolver) Solves linear
+%   equations iteratively, using a geometric multigrid solver with V-cycle and
+%   specified MultiLevelSmoother.
+%
+%   To obtain the solver described in [Innerberger, Miraçi, Praetorius,
+%   Streitberger; Optimal computational costs of AFEM with optimal local
+%   hp-robust multigrid solver], choose the smoother as DiagonalJacobi (p=1),
+%   LowOrderBlockJacobi (p > 1, 1->1->p iteration), or HighOrderBlockJacobi
+%   (p > 1, 1->p->p iteration).
+%   This solver also comes with built-in estimator for the algebraic error,
+%   which is also returned by this multigrid solver.
+%
+% See also IterativeSolver, MultiLevelSmoother, DiagonalJacobi,
+%   LowOrderBlockJacobi, HighOrderBlockJacobi
+
+classdef OptimalVcycleMultigridSolver < IterativeSolver
+    %% properties 
+    properties (SetAccess=protected, GetAccess=public)
+        residualCNorm
+        algEstimator
+        residual
+        Cresidual
+    end
+
+    properties (Dependent, Access=public)
+        nLevels
+    end
+    
+    properties (Access=protected)
+        smoother
+        matrix
+        lhsNorm
+    end
+    
+    methods
+        function obj = OptimalVcycleMultigridSolver(smoother)
+            obj = obj@IterativeSolver();
+            obj.smoother = smoother;
+        end
+
+        function n = get.nLevels(obj)
+            n = obj.smoother.nLevels;
+        end
+
+        function setupSystemMatrix(obj, A, varargin)
+            % override superclass method, because ALL matrices are needed
+            setupSystemMatrix@IterativeSolver(obj, A, varargin{:});
+
+            obj.smoother.setup(A, varargin{:});
+            obj.matrix{obj.nLevels} = A;
+        end
+
+        function setupRhs(obj, b, varargin)
+            setupRhs@IterativeSolver(obj, b, varargin{:});
+            % initialize residual & hierarchy
+            obj.residual = b - obj.A * obj.x;
+            [obj.Cresidual, obj.algEstimator] = obj.Vcycle(obj.residual);
+            obj.residualCNorm = sqrt(dot(obj.residual, obj.Cresidual, Dim.Vector));
+            obj.lhsNorm = vecnorm(b, 2, Dim.Vector);
+        end
+        
+        % stopping criterion
+        function tf = isConverged(obj)
+            tf = ((obj.iterationCount >= obj.maxIter) ...
+                | (obj.residualCNorm < obj.tol) ...
+                | (obj.residualCNorm ./ obj.lhsNorm < obj.tol)); 
+        end
+    end
+        
+    methods (Access=protected)   
+        % one step
+        function computeUpdate(obj)
+            % only iterate on active components
+            idx = find(obj.activeComponents);
+            
+            % update solution & residual
+            obj.x(:,idx) = obj.x(:,idx) + obj.Cresidual(:,idx);
+            obj.residual(:,idx) = obj.residual(:,idx) - obj.A * obj.Cresidual(:,idx);
+
+            % residual & update error correction
+            [obj.Cresidual(:,idx), obj.algEstimator(idx)] = obj.Vcycle(obj.residual(:,idx));
+            obj.residualCNorm(:,idx) = sqrt(dot(obj.residual(:,idx), obj.Cresidual(:,idx), Dim.Vector));
+        end
+
+        function [Cx, algEstimator] = Vcycle(obj, x)
+            assert(isequal(size(x, 1), size(obj.A, 1)), ...
+                'Setup for multilevel iteration not complete!')
+            
+            % if there is only one coarse level: exact solve
+            L = obj.nLevels;
+            if L == 1
+                Cx = obj.A \ x;
+                algEstimator = sqrt(dot(Cx, obj.A * Cx, Dim.Vector));
+                return
+            end
+
+            % descending cascade: no smoothing
+            res{L} = x;
+            for k = L:-1:2
+                res{k-1} = obj.smoother.restrict(res{k}, k);
+            end
+
+            % exact solve on coarsest level to compute accumulative lifting of x
+            Cx = obj.matrix{1} \ res{1};
+            eta2 = dot(Cx, obj.matrix{1} * Cx, Dim.Vector);
+
+            % ascending cascade: (local) smoothing and compute error estimator
+            for k = 2:L
+                Cx = obj.smoother.prolongate(Cx, k);
+                updatedResidual = res{k} - obj.matrix{k} * Cx;
+                rho = obj.smoother.smooth(updatedResidual, k);
+                [etaUpdate2, xUpdate] = computeOptimalUpdate(obj.matrix{k}, updatedResidual, rho);
+                Cx = Cx + xUpdate;
+                eta2 = eta2 + etaUpdate2;
+            end
+
+            algEstimator = sqrt(eta2);
+        end
+    end
+end
+
+%% auxiliary functions
+% error correction with optimal stepsize 
+function [etaUpdate2, resUpdate] = computeOptimalUpdate(A, res, rho)
+    rhoArho = dot(rho, A*rho, Dim.Vector);
+    if max(abs(rho)) < eps
+        lambda = 1; 
+    else
+        lambda = dot(res, rho, Dim.Vector) ./ rhoArho;
+    end
+    resUpdate = lambda .* rho;
+    etaUpdate2 = rhoArho .* lambda.^2;
+    
+    if any(lambda > 3)
+       warning('MG step-sizes no longer bound by d+1. Optimality of step size cannot be guaranteed!')
+    end
+end
diff --git a/lib/solvers/chooseIterativeSolver.m b/lib/solvers/chooseIterativeSolver.m
index 02d735c3b8fb17f82f0f0c2bae9c2a5658f8383a..fa30db22d2f6701c3fc1c43c78c4b31d43c63937 100644
--- a/lib/solvers/chooseIterativeSolver.m
+++ b/lib/solvers/chooseIterativeSolver.m
@@ -53,13 +53,13 @@ switch class
         switch variant
             case {"", "lowOrderVcycle"}
                 if order == 1
-                    solver = LowestOrderLocalMg(fes, blf, P);
+                    solver = OptimalVcycleMultigridSolver(P1JacobiSmoother(fes, blf, P));
                 else
                     solver = LocalMgLowOrderVcycle(fes, blf);
                 end
             case "highOrderVcycle"
                 if order == 1
-                    solver = LowestOrderLocalMg(fes, blf, P);
+                    solver = OptimalVcycleMultigridSolver(P1JacobiSmoother(fes, blf, P));
                 else
                     solver = LocalMgHighOrderVcycle(fes, blf, P);
                 end
diff --git a/lib/solvers/smoothers/MultilevelSmoother.m b/lib/solvers/smoothers/MultilevelSmoother.m
new file mode 100644
index 0000000000000000000000000000000000000000..45ff623060c116210a886c73c0587de8027eb30a
--- /dev/null
+++ b/lib/solvers/smoothers/MultilevelSmoother.m
@@ -0,0 +1,40 @@
+% MultilevelSmoother (abstract handle class) Interface for smoothers to use in a
+%   multigrid solver.
+%
+% Abstract methods to implement:
+%
+% smoother.setup(A) hook to set up the smoother for the matrix A.
+%
+% Cx = smoother.smooth(x, k) applies the smoother to the vector x on level k.
+%
+% Px = smoother.prolongate(x, k) prolongate vector x from level k to level k+1.
+%
+% Px = smoother.restrict(x, k) restrict vector x from level k+1 to level k.
+%
+% See also: OptimalVcycleMultigridSolver
+
+
+classdef MultilevelSmoother < handle
+    %% properties
+    properties (GetAccess=public, SetAccess=protected)
+        nLevels
+    end
+
+    %% methods
+    methods (Access=public)
+        function obj = MultilevelSmoother()
+            obj.nLevels = 0;
+        end
+
+        function setup(obj, ~)
+            obj.nLevels = obj.nLevels + 1;
+        end
+    end
+
+    %% abstract methods
+    methods (Abstract, Access=public)
+        smooth(obj, x, k)
+        prolongate(obj, x, k)
+        restrict(obj, x, k)
+    end
+end
\ No newline at end of file
diff --git a/lib/solvers/smoothers/P1JacobiSmoother.m b/lib/solvers/smoothers/P1JacobiSmoother.m
new file mode 100644
index 0000000000000000000000000000000000000000..59d4311240877cbe1245f1d580ae5e4aa8e43a86
--- /dev/null
+++ b/lib/solvers/smoothers/P1JacobiSmoother.m
@@ -0,0 +1,91 @@
+% P1JacobiSmoother (subclass of MultilevelSmoother) multilevel Jacobi smoother
+%   for lowest order finite elements: smooth with diagonal of stiffness matrix
+%   on changed patches on every level.
+%
+% See also: MultilevelSmoother, OptimalVcycleMultigridSolver
+
+
+classdef P1JacobiSmoother < MultilevelSmoother
+    properties (Access=protected)
+        fes
+        blf
+        inverseDiagonal
+        P
+        intergridMatrix
+        freeVertices
+        freeVerticesOld
+        changedPatches
+    end
+    
+    %% event data
+    properties (Access=private)
+        listenerHandle
+    end
+
+    %% methods
+    methods (Access=public)
+        function obj = P1JacobiSmoother(fes, blf, P)
+            arguments
+                fes FeSpace
+                blf BilinearForm
+                P Prolongation
+            end
+            obj = obj@MultilevelSmoother();
+            
+            assert(fes.finiteElement.order == 1, ...
+                'P1JacobiSmoother only works for lowest order finite elements.')
+            
+            obj.fes = fes;
+            obj.P = P;
+            obj.blf = blf;
+            
+            mesh = fes.mesh;
+            obj.listenerHandle = mesh.listener('IsAboutToRefine', @obj.getChangedPatches);
+        end
+
+        function setup(obj, A)
+            setup@MultilevelSmoother(obj, A);
+            
+            L = obj.nLevels;
+            obj.freeVerticesOld = obj.freeVertices;
+            obj.freeVertices = getFreeDofs(obj.fes);
+            
+            obj.inverseDiagonal{L} = full(diag(A)).^(-1);
+            
+            if L >= 2
+                obj.intergridMatrix{L} = obj.P.matrix(obj.freeVertices, obj.freeVerticesOld);
+                obj.changedPatches{L} = find(obj.changedPatches{L}(obj.freeVertices));
+                obj.inverseDiagonal{L} = obj.inverseDiagonal{L}(obj.changedPatches{L},:);
+            end
+        end
+
+        function Cx = smooth(obj, x, k)
+            idx = obj.changedPatches{k};
+            Cx = zeros(size(x));
+            Cx(idx,:) = obj.inverseDiagonal{k} .* x(idx,:);
+        end
+
+        function Px = prolongate(obj, x, k)
+            Px = obj.intergridMatrix{k} * x;
+        end
+
+        function Px = restrict(obj, x, k)
+            Px = obj.intergridMatrix{k}' * x;
+        end
+    end
+    
+    methods (Access=protected)
+        % callback function to be executed before mesh refinement:
+        % -> patches change for all vertices adjacent to refined edges and
+        %    all new vertices
+        function getChangedPatches(obj, mesh, bisecData)
+            nCOld = mesh.nCoordinates;
+            nCNew = mesh.nCoordinates + nnz(bisecData.bisectedEdges) + ...
+                bisecData.nRefinedElements'*bisecData.nInnerNodes;
+            bisectedEdgeNodes = unique(mesh.edges(:,bisecData.bisectedEdges));
+            obj.changedPatches{obj.nLevels+1} = false(nCNew, 1);
+            idx = [unique(bisectedEdgeNodes); ((nCOld+1):nCNew)'];
+            obj.changedPatches{obj.nLevels+1}(idx) = true;
+        end
+    end
+end
\ No newline at end of file