Island Counting Algorithm

Curt Hill

Given a grid with rows and columns (m x n matrix), each cell in the grid can have a value of 0 (water) or 1 (land). Create an algorithm to count the number of islands and lakes. Islands are groups of adjacent land cells. Two cells are adjacent if they are horizontal or vertical neighbors. Diagonal neighbors can also (optionally) be considered adjacent.

Lakes are groups of adjacent water cells that are surrounded by land cells.

The grid is represented in code as an undirected graph. Counting islands is accomplished by doing a depth first search (DFS) on land nodes to recursively find all adjacent nodes.

Counting lakes is accomplished by a DFS on adjacent water nodes to make sure none of the nodes are at the edge of the graph. A group of water nodes having any node at the edge of the graph would mean that the entire group was not completely surrounded by land, hence not a lake.

Each node is marked as visited as it is traversed so that it will be considered only once.

The asymptotic time complexity of the algorithm is O(m * n) (matrix rows * columns).

grid with 4 islands and 1 lake. Note that the land cells that are diagonal neighbors are separate islands by default. When the ‘Diagonal Islands’ button is clicked, diagonal neighbors are part of the same island.

The algorithm begins be defining the node and grid objects in the pseudo classical pattern. The grid constructor creates a two dimensional array of nodes, with each node having a (zero based) x & y property indicating its position in the grid.

            
var Node = function (x, y) {
    this.x = x;
    this.y = y;
    this.val = 0;  
    this.visited = false;  
    this.type = constants.nodeTypes.unknown;   
}

var Datagrid = function (x, y) {
    this.rows = x;  
    this.cols = y;  
    this.nodes = new Array();  
    this.includeDiagonal = false;  
    
    var nodesY; 

    //initialize 2d array
    for (var i = 0; i < this.rows; i++) {
        nodesY = new Array();
        for (var j = 0; j < this.cols; j++) {
            nodesY.push(new Node(i, j));
        }
        this.nodes.push(nodesY);
    }
};
            
        

Grid Object Functions

exists(x, y): returns true if x and y are valid (within range) for the grid.

get(x, y): returns the node at position x, y. An error is thrown if x and y are out of range.

next(node): This function allows us to easily visit every node in the grid, from the upper left corner (first row & column) to the lower right (last row last column). (See forEachNode function)

hasNeighbor(node, direction, testCondition): If there is not a neighbor in the direction provided, returns false. For example, if input node is top row and direction is up, there is no neighbor above the first row and false is returned. If there is a neighbor, then the testCondition is run. testCondition is a first class function with node as input which returns true if the neighbor meets a defined condition. For example, when counting islands, the condition would test that node.val === 1 (land) since only land nodes are considered when grouping islands.

neighbor (node, direction): Returns the neighbor of the input node in the direction provided. Returns null if there is no neighbor in the direction provided.

neighbors (node, directionType): Returns neighbors of the input node. directionType is a property of the directionTypes object and is either perpendicular or all.

neighbor (node, direction): Returns neighbor of input node in the specified direction. If none exists, returns null.

neighbors (node, directionType): Returns an array of neighbors of the input node. directionType is a property of the directionTypes object and will be either perpendicular or all. If perpendicular, only perpendicular (horizontal and vertical) neighbors are returned. If all , perpendicular and diagonal neighbors are returned.

With diagonal islands unchecked, land nodes do not touch at corners. The 2 water nodes are part of the sane lake.
With diagonal checked, land nodes touch at corners, separating the water into 2 lakes.

resetNode: Resets all nodes for recounting

countIsles: Counts all islands. Two first class functions are created. Function includeNodeTest returns true if node.val === 1. This restricts the counting algorithm to land nodes only. The second function, validGroupTest, is a test performed on a group of nodes to determine if the group is valid as an island. Since all groups of adjacent land cells are valid islands, this function always returns true. Finally, the island are counted by a call to countGroup.

countLakes: Count all lakes. As in countIsles, two first class functions are created. includeNodeTest returns true for water nodes (node.val === 0). validGroupTest returns true unless any node in the group (lake) has less than 4 perpendicular neighbors. Only nodes that are at the edge of the graph will have < 4 perpendicular neighbors. So any body of water with a node at the edge is not a lake, because by definition, a lake must be surrounded by land, and a water node at the edge of the graph cannot be surrounded by land.

The island on the right does not contain a lake since the water is not completely surrounded by land.

findGroups (includeNodeTest, validGroupTest, directionType, nodeType): Traverses all the nodes in the graph from upper left to lower right. For each node, if it is the type we are looking for (either land or water as determined by includeNodeTest) call addToGroup to recursively add all neighbors to the group and increase the group count by 1. Functions findGroups and addToGroup mark each node they touch as visited. This prevents any node from being considered for any future groups (a node can only be part of one island or lake). Since nodes are considered once, runtime complexity is O(m * n).

addToGroup (node, includeNodeTest, validGroupTest, directionType): Recursively finds all nodes in the same group as the input node. Only considers nodes that pass the includeNodeTest (land or water). validGroupTest is used only for lakes, and disqualifies any bodies of water that have a water node at the edge of the graph. Direction type indicates whether searching is perpendicular only, or all directions.

forEachNode: Function that can be called for any action that needs to be performed on all the nodes in the graph. Action to be performed is passed in as a first class function (doThis).

In addition to the Grid object, there is a GridUX object which handles the details of rendering the grid on the canvas. I relied on JCanvas for canvas interactions and JQuery UI for displaying the read me.

Finally, there is a MakeGrid object which uses the Grid and GridUX objects the draw a blank grid on the page. MakeGrid exposes functions clear, reverse and toggleDiagonal which are called in response to clicking the Clear button, Reverse button, and Diagonal Islands respectively. Each of these functions is a closure. That is, they close over the Grid & GridUX objects. As a result, both Grid & GridUX are available to the click events without being in the global scope.

This algorithm could be used for image processing, with each ‘node’ representing a pixel image. By altering validGroupTest function, image pixels could be grouped on any property of the pixel.

            
/*
islands.js
2/5/2015
curt hill
cs - recursive graph traversal
*/

var directionTypes = {
    perpendicular: 'perpendicular',
    diagonal: 'diagonal',
    all: 'all'
}

var constants = {
    directions: {
        up: {
            val: 'up',
            type: directionTypes.perpendicular
        },
        down: {
            val: 'down',
            type: directionTypes.perpendicular
        },
        left: {
            val: 'left',
            type: directionTypes.perpendicular
        },
        right: {
            val: 'right',
            type: directionTypes.perpendicular
        },
        ur: {
            val: 'ur',
            type: directionTypes.diagonal
        },
        ul: {
            val: 'ul',
            type: directionTypes.diagonal
        },
        lr: {
            val: 'lr',
            type: directionTypes.diagonal
        },
        ll: {
            val: 'll',
            type: directionTypes.diagonal
        }
    },
    nodeTypes: {
        unknown: 'unknown',
        lake: 'lake',
        island: 'island'
    }
};


var Node = function (x, y) {
    this.x = x;
    this.y = y;
    this.val = 0;  //0 = water, 1=land
    this.visited = false;  //has node already been visited in depth first search
    this.type = constants.nodeTypes.unknown;   //lake, island, or unknown
}

var DataGrid = function (x, y) {
    this.rows = x;  //1 based number of rows
    this.cols = y;  //1 based number of columns
    this.nodes = new Array();  //2 dimensional array of node objects 
    this.includeDiagonal = false;  //false - only horizaontal & vertical neighbors are considered adjacent for island grouping. true: diagonal land nodes are also adjacent
    
    var nodesY; // (2nd dimension of 2d array)

    //initialize 2d array
    for (var i = 0; i < this.rows; i++) {
        nodesY = new Array();
        for (var j = 0; j < this.cols; j++) {
            nodesY.push(new Node(i, j));
        }
        this.nodes.push(nodesY);
    }
};

DataGrid.prototype = {
    exists: function (x, y) {
        //x & y are zero based. this.rows & this.cols are 1 based
        //returns true if x and y are within the range for grid rows and columns
        return (x >= 0 && y >= 0 && x < this.rows && y < this.cols);
    },
    get: function (x, y) {
        //returns node at x, y. throws error is x or y are out of bounds
        if (!this.exists(x, y)) {
            throw new Error('There is not a node at ' + x + ',' + y);
        }
        return this.nodes[x][y];
    },
    next: function (node) {
        //returns next node on same row as input node. 
        //if input node is at end of row, returns first node at beginning of next row. 
        //if input node is at the last row and last column, returns null

        //returns next node in grid from upper left to lower right order or null when at the end
        var currX = node.x;
        var currY = node.y;

        //this is last node, so there is no next node
        if (currX === this.rows - 1 && currY === this.cols - 1) return null;

        //at last column, go to next row, first column
        if (currY === this.cols - 1) return this.nodes[currX + 1][0];

        //else, go to next column
        return this.nodes[currX][currY + 1];
    },
    hasNeighbor: function (node, direction, testCondition) {
        //direction constants.directions
        //testCondition is function with node as input parameter which returns true/false 
        var nbr = this.neighbor(node, direction);
        return (nbr === null) ? false : testCondition(nbr);
    },
    neighbor: function (node, direction) {
        //returns the neighbor of the input node in direction provided
        //direction is either constants.directions

        var valid = false;
        for (var dir in constants.directions) {
            if (constants.directions[dir].val === direction) valid = true;
        }

        if (!valid) throw new Error("'" + direction + "' is an invalid direction. Must be 'up', 'down', 'left', 'right', 'ur', 'ul', 'lr', 'll'");

        var offset_x = 0; //x offset of neighbor from input node
        var offset_y = 0; //y offset of neighbor from input node

        if (direction === constants.directions.up.val ||
            direction === constants.directions.ul.val ||
            direction === constants.directions.ur.val) { offset_x = -1; }
        if (direction === constants.directions.down.val ||
            direction === constants.directions.ll.val ||
            direction === constants.directions.lr.val) { offset_x = 1; }
        if (direction === constants.directions.left.val ||
            direction === constants.directions.ul.val ||
            direction === constants.directions.ll.val) { offset_y = -1; }
        if (direction === constants.directions.right.val ||
            direction === constants.directions.ur.val ||
            direction === constants.directions.lr.val) { offset_y = 1; }

        var nbr_x = node.x + offset_x;
        var nbr_y = node.y + offset_y;

        return this.exists(nbr_x, nbr_y) ? this.nodes[nbr_x][nbr_y] : null;

    },
    neighbors: function (node, directionType) {
        //returns an array of nodes which are neighbors of the input node
        //directionType is property of directionTypes object
        //directionType = perpendicular, returns neighbors above, below, left & right
        //directionType = all includes perpendicular and diagonal
        if (directionType != directionTypes.perpendicular && directionType != directionTypes.all) {
            throw new Error('Direction type must be \'' + directionTypes.perpendicular
            + ' or \'' + directionTypes.all + '\'');
        }

        var nbrs = new Array();

        for (var i in constants.directions) {
            var dir = constants.directions[i];
            if (directionType === directionTypes.perpendicular && dir.type === directionTypes.diagonal) continue;
            var nbr = this.neighbor(node, dir.val);

            if (nbr !== null) {
                nbrs.push(nbr);
            }
        }

        return nbrs;
    },
    resetNodes: function () {
        this.forEachNode(function (n) {
            n.visited = false;
            n.type = constants.nodeTypes.unknown;
        });
    },
    countIsles: function () {
        var includeNodeTest = function (node) { return node.val === 1; };
        var validGroupTest = function (grid, node) { return true; };
        var directionType = this.includeDiagonal ? directionTypes.all : directionTypes.perpendicular;
        return this.findGroups(includeNodeTest, validGroupTest, directionType, constants.nodeTypes.island);
    },
    countLakes: function () {
        var includeNodeTest = function (node) { return node.val === 0; };
        var validGroupTest = function (grid, node) { return grid.neighbors(node, directionTypes.perpendicular).length === 4; };
        var directionType = this.includeDiagonal ? directionTypes.perpendicular : directionTypes.all;
        return this.findGroups(includeNodeTest, validGroupTest, directionType, constants.nodeTypes.lake);
    },
    findGroups: function (includeNodeTest, validGroupTest, directionType, nodeType) {
        //count groupd of nodes based on some node condition. 
        //all of the nodes in groups touch eachother horizontally or vertically
        //directionType = perpendicular or all to include only perpendicular neighbors in group or all neighbors (perpendicular & diagonal)
        this.resetNodes();

        var groupCnt = 0;
        var thisGrid = this;
        this.forEachNode(
            function (node) {
                if (node.visited) return;
                node.visited = true;

                if (!includeNodeTest(node)) return;

                thisGrid.nodeCache = new Array();
                thisGrid.validGroup = true; //until proven otherwise
                
                //this = window here, so rely on thisGrid to get to this grid object
                thisGrid.addToGroup(node, includeNodeTest, validGroupTest, directionType); //recursively add all neighbors (and neighbors of neighbors) to this group
                
                if (!thisGrid.validGroup) return;
                groupCnt++;

                //console.log(thisGrid.nodeCache);
                //console.log(thisGrid.nodeCache.length);
                //console.log(thisGrid.validGroup);

                //set the node type for all the nodes in the group
                while (thisGrid.nodeCache.length > 0) {
                    var n = thisGrid.nodeCache.pop();
                    n.type = nodeType;
                }
            }
        );  //this.forEachNode(

        return groupCnt;
    },
    addToGroup: function (node, includeNodeTest, validGroupTest, directionType) {
        //recursively add all neighbors (& neighbors of neighbors) that meet includeNodeTest to the same island as the input node
        //includeNodeTest - (first class) function which returns true if the node is to be included
        //validGroupTest - (first class) function which returns false if some property of the node renders the entire group invalid
        //directionType - a property of directionTypes object indicates whether we consider only perpendicular nodes or all nodes when searching

        this.nodeCache.push(node);
        var neighbors = this.neighbors(node, directionType);  //neighbors is an array of nodes adjacent to the input node

        while (neighbors.length > 0) {
            var neighbor = neighbors.pop();
            if (!neighbor.visited) {
                neighbor.visited = true;
                if (includeNodeTest(neighbor)) this.addToGroup(neighbor, includeNodeTest, validGroupTest, directionType);
            }
        }
        if (!validGroupTest(this, node)) this.validGroup = false;
    },


    forEachNode: function (doThis) {
        //do something to every node in the graph
        var n = this.get(0, 0);
        while (n !== null) {
            doThis(n);
            n = this.next(n);
        }
    }
}

var GridUX = function (grid, landColor, waterColor) {
    this.grid = grid;
    this.landColor = landColor;
    this.waterColor = waterColor;
}

GridUX.prototype = {
    cellClick: function (cellName, rotation) {
        var cellNameXY = cellName.substr(1, cellName.length - 1);
        var xy = cellNameXY.split(',');
        var x = parseInt(xy[0]);
        var y = parseInt(xy[1]);
        var fill;
        var stroke;

        var node = this.grid.get(x, y);
        if (node.val === 0) {
            node.val = 1;
            fill = this.landColor;
            stroke = this.waterColor;
        } else {
            node.val = 0;
            fill = this.waterColor;
            stroke = this.landColor;
        }

        $('#count').html(this.grid.countIsles());
        $('#lakecount').html(this.grid.countLakes());

        this.drawEdges();

        $('canvas').animateLayer(cellName, {
            fillStyle: fill,
            strokeStyle: stroke
        });



    },
    drawGrid: function () {
        var size = 440 / this.grid.rows;
        var fill;
        var stroke;

        $('canvas').clearCanvas();
        $('canvas').removeLayers();

        for (var row = 1; row <= this.grid.rows; row++) {
            for (var col = 1; col <= this.grid.cols; col++) {
                var cellName = 'l' + (row - 1) + ',' + (col - 1);
                var currNode = this.grid.get(row - 1, col - 1);

                if (currNode.val === 1) {
                    fill = this.landColor;
                    stroke = this.waterColor;
                } else {
                    fill = this.waterColor;
                    stroke = this.landColor;
                }

                var multiplier = 0.7;

                //add new ux related properties on the fly
                currNode.width = size * multiplier;
                currNode.height = size * multiplier;
                currNode.xcoord = (col * size * multiplier);
                currNode.ycoord = (row * size * multiplier);

                var thisObj = this;
                $('canvas').drawRect({
                    layer: true,
                    name: cellName,
                    fillStyle: fill,
                    strokeStyle: stroke,
                    x: currNode.xcoord,
                    y: currNode.ycoord,
                    width: currNode.width,
                    height: currNode.height,
                    cornerRadius: size * .00,
                    click: function (layer) {
                        thisObj.cellClick(layer.name);
                    }
                });
            }
        }
        $('#count').html(this.grid.countIsles());
        $('#lakecount').html(this.grid.countLakes());
        this.drawEdges();
    },
    drawEdges: function () {
        var thisGrid = this.grid;
        thisGrid.forEachNode(
            function (n) {
                var layerName = 'layer_' + n.x + ',' + n.y;

                for (var i in constants.directions) {
                    var direction = constants.directions[i];

                    if (direction.type === directionTypes.diagonal) continue;  //edges are between perpendicular

                    var xoffset = n.width / 2;
                    var yoffset = n.height / 2;
                    var xlength = 0;
                    var ylength = 0;

                    if (direction.val === constants.directions.left.val || direction.val === constants.directions.right.val) {
                        ylength = n.height;
                    } else {
                        xlength = n.width;
                    }

                    xoffset = (direction.val === constants.directions.right.val) ? xoffset : -n.width / 2;
                    yoffset = (direction.val === constants.directions.down.val) ? yoffset : -n.height / 2;

                    var x1 = n.xcoord + xoffset;
                    var y1 = n.ycoord + yoffset;
                    var x2 = x1 + xlength;
                    var y2 = y1 + ylength;

                    var edgeColor = '#0f0';  //green border

                    $('canvas').removeLayer(layerName + direction.val);

                    if (n.val === 0) {
                        //water cells do not have edges
                        continue;
                    }

                    var val_1 = function (checkNode) { return (checkNode.val === 1); }
                    if (thisGrid.hasNeighbor(n, direction.val, val_1)) {
                        //if land with land neighbor, no edge 
                       continue;
                    }

                    var lakeNeighbor = function (checkNode) { return (checkNode.type === constants.nodeTypes.lake); }
                    if (thisGrid.hasNeighbor(n, direction.val, lakeNeighbor)) {
                        edgeColor = '#00f';  //blue border for lakes!
                    }

                    $('canvas').drawLine({
                        layer: true,
                        name: layerName + direction.val,
                        strokeStyle: edgeColor,
                        strokeWidth: 4,
                        x1: x1, y1: y1,
                        x2: x2, y2: y2
                    });
                }  
            } //function(n) {
        ); //thisGrid.forEachNode(
    }  //drawEdges: function () {
}

var MakeGrid = function (r, c) {
    var grid = new DataGrid(r, c);
    var landColor = '#eee';
    var waterColor = '#666'
    var ux = new GridUX(grid, landColor, waterColor);
    ux.drawGrid();
    this.clear = function () {
        //clear returns a closure with access to grid and ux
        return function () {
            grid = new DataGrid(r, c);
            grid.includeDiagonal = $('#diagonals').is(":checked");
            ux = new GridUX(grid, landColor, waterColor);
            ux.drawGrid();
        }
    }
    this.reverse = function () {
        //reverse returns a closure with access to grid and ux
        return function () {
            grid.forEachNode(
                function (node) {
                    node.val = (node.val === 1) ? 0 : 1;
                }
            );
            ux = new GridUX(grid, landColor, waterColor);
            ux.drawGrid();
        }
    }
    this.toggleDiagonal = function () {
        return function () {
            grid.includeDiagonal = $('#diagonals').is(":checked");
            ux = new GridUX(grid, landColor, waterColor);
            ux.drawGrid();
        }
    }
    this.getGrid = function () { return grid; }  //helpful for debugging, but remove if you want to keep grid private
}

var mg = new MakeGrid(8, 8);
var g = mg.getGrid();  //comment this out to keep grid object private

$('#clear').on('click', mg.clear());
$('#reverse').on('click', mg.reverse());
$('#diagonals').on('change', mg.toggleDiagonal());
$("#readme").click(function () {
    $("#dialog").dialog("open");
});


//$( elem ).prop( "checked" )

$(function () {
    var myPos;
    var atPos;
    if ($(window).width() <= 340) {
        myPos = "center top";
        atPos = "center bottom";
    } else {
        myPos = "right top";
        atPos = "right+20 bottom+10";
    }
    $("#dialog").dialog({
        position: { my: myPos, at: atPos, of: "#readme" },
        width: 350,
        autoOpen: false,
        show: {
            effect: "blind",
            duration: 400
        },
        hide: {
            effect: "blind",
            duration: 400
        }
    });

});