LeetCode 588 - Design In-Memory File System

The problem asks us to design an in-memory file system that simulates basic file system operations without interacting with the real filesystem.

LeetCode Problem 588

Difficulty: 🔴 Hard
Topics: Hash Table, String, Design, Trie, Sorting

Solution

Problem Understanding

The problem asks us to design an in-memory file system that simulates basic file system operations without interacting with the real filesystem. We need to implement a FileSystem class that supports creating directories, adding and reading file content, and listing files or directories in a given path.

The input consists of paths, which are absolute (starting with /) and only contain lowercase letters. Each operation affects or queries the virtual file system structure. The expected output varies depending on the operation: ls returns a list of file or directory names, mkdir and addContentToFile modify the system with no return, and readContentFromFile returns the file content as a string.

Constraints indicate that paths are valid, their length is reasonable, and there are at most 300 operations. This suggests that our solution should focus on clarity and correctness, rather than extreme optimization. Edge cases include querying the root /, adding content to existing files, and paths that involve nested directories.

The key challenge is maintaining an efficient and easy-to-traverse representation of the filesystem, supporting fast lookups, insertions, and content storage.

Approaches

The brute-force approach would be to use a flat list or array to store every path and file content. Every ls, mkdir, or addContentToFile operation would involve scanning the entire list to find the matching directory or file. While this would work functionally, it would be slow for nested paths or frequent operations, with O(n) complexity per operation.

The optimal approach is to use a trie-like tree structure where each node represents a directory or file. Each directory node stores a dictionary mapping names to child nodes. File nodes store their content as a string. This allows O(k) operations, where k is the number of components in the path, for creation, lookup, or content reading. Lexicographic ordering for ls can be handled by sorting keys of the dictionary when returning results.

Approach Time Complexity Space Complexity Notes
Brute Force O(n) per operation O(n) Store paths in a list; requires linear scans for lookups
Optimal O(k log k) for ls, O(k) for other ops O(total nodes + total content) Use trie/tree structure; k is number of path components

Algorithm Walkthrough

  1. Define a Node class that represents either a directory or a file. Directory nodes have a dictionary for children, file nodes have a string for content.
  2. Initialize the root of the filesystem as an empty directory node.
  3. Implement mkdir(path) by splitting the path into components and traversing the trie, creating any missing directories along the path.
  4. Implement addContentToFile(filePath, content) by traversing to the file path, creating a new file node if it does not exist, and appending the content.
  5. Implement readContentFromFile(filePath) by traversing to the file node and returning its content.
  6. Implement ls(path) by traversing to the given path. If it is a file, return its name in a list. If it is a directory, return the sorted list of child names.

Why it works: The trie structure preserves the hierarchical nature of file systems. Each operation works by following the path components from root to the target node. Creating directories or files along the path guarantees the structure remains consistent. The invariant is that the trie always accurately represents the filesystem.

Python Solution

from typing import List, Dict

class Node:
    def __init__(self):
        self.children: Dict[str, Node] = {}
        self.content: str = ""
        self.is_file: bool = False

class FileSystem:

    def __init__(self):
        self.root = Node()

    def ls(self, path: str) -> List[str]:
        node = self.root
        if path != "/":
            parts = path.split("/")[1:]
            for part in parts:
                node = node.children[part]
            if node.is_file:
                return [parts[-1]]
        return sorted(node.children.keys())

    def mkdir(self, path: str) -> None:
        node = self.root
        parts = path.split("/")[1:]
        for part in parts:
            if part not in node.children:
                node.children[part] = Node()
            node = node.children[part]

    def addContentToFile(self, filePath: str, content: str) -> None:
        node = self.root
        parts = filePath.split("/")[1:]
        for part in parts[:-1]:
            node = node.children[part]
        if parts[-1] not in node.children:
            node.children[parts[-1]] = Node()
            node.children[parts[-1]].is_file = True
        node.children[parts[-1]].content += content

    def readContentFromFile(self, filePath: str) -> str:
        node = self.root
        parts = filePath.split("/")[1:]
        for part in parts[:-1]:
            node = node.children[part]
        return node.children[parts[-1]].content

The Python implementation defines a Node class for directories and files. ls handles the root directory, files, and lexicographic ordering. mkdir and addContentToFile traverse the path, creating intermediate nodes if missing. readContentFromFile simply follows the path to the file and returns content.

Go Solution

package main

import (
    "sort"
    "strings"
)

type Node struct {
    children map[string]*Node
    content  string
    isFile   bool
}

type FileSystem struct {
    root *Node
}

func Constructor() FileSystem {
    return FileSystem{root: &Node{children: make(map[string]*Node)}}
}

func (this *FileSystem) Ls(path string) []string {
    node := this.root
    if path != "/" {
        parts := strings.Split(path, "/")[1:]
        for _, part := range parts {
            node = node.children[part]
        }
        if node.isFile {
            return []string{parts[len(parts)-1]}
        }
    }
    res := make([]string, 0, len(node.children))
    for name := range node.children {
        res = append(res, name)
    }
    sort.Strings(res)
    return res
}

func (this *FileSystem) Mkdir(path string) {
    node := this.root
    parts := strings.Split(path, "/")[1:]
    for _, part := range parts {
        if _, ok := node.children[part]; !ok {
            node.children[part] = &Node{children: make(map[string]*Node)}
        }
        node = node.children[part]
    }
}

func (this *FileSystem) AddContentToFile(filePath string, content string) {
    node := this.root
    parts := strings.Split(filePath, "/")[1:]
    for _, part := range parts[:len(parts)-1] {
        node = node.children[part]
    }
    if _, ok := node.children[parts[len(parts)-1]]; !ok {
        node.children[parts[len(parts)-1]] = &Node{children: make(map[string]*Node), isFile: true}
    }
    node.children[parts[len(parts)-1]].content += content
}

func (this *FileSystem) ReadContentFromFile(filePath string) string {
    node := this.root
    parts := strings.Split(filePath, "/")[1:]
    for _, part := range parts[:len(parts)-1] {
        node = node.children[part]
    }
    return node.children[parts[len(parts)-1]].content
}

The Go implementation mirrors Python, but uses explicit map initialization and type declarations. Sorting is done using the standard sort package. Go requires careful handling of slices and maps, especially when creating new nodes.

Worked Examples

Example 1:

Operations: ["FileSystem", "ls", "mkdir", "addContentToFile", "ls", "readContentFromFile"]
Arguments:  [[], ["/"], ["/a/b/c"], ["/a/b/c/d", "hello"], ["/"], ["/a/b/c/d"]]

Step by step:

  1. Initialize filesystem. Root node is empty directory.
  2. ls("/") → root has no children → return [].
  3. mkdir("/a/b/c") → create nodes for a → b → c.
  4. addContentToFile("/a/b/c/d", "hello") → create file d in c and set content.
  5. ls("/") → root has child a → return ["a"].
  6. readContentFromFile("/a/b/c/d") → return "hello".

Complexity Analysis

Measure Complexity Explanation
Time O(k log k) for ls, O(k) for others k is number of path components
Space O(total nodes + total content) Each directory and file uses one node

The main cost is traversing the path components. Sorting in ls requires O(k log k) where k is the number of entries in the directory.

Test Cases

fs = FileSystem()

# basic operations
assert fs.ls("/") == []  # root initially empty
fs.mkdir("/a/b/c")
fs.addContentToFile("/a/b/c/d", "hello")
assert fs.ls("/") == ["a"]  # directory listing
assert fs.readContentFromFile("/a/b/c/d") == "hello"  # file content

# append content
fs