Algorithms

Leetcode - Valid Number Solution

11 min read
AlgorithmsLeetCodeString ProcessingState MachineJavaScript

Determining if a string represents a valid number sounds straightforward, but the edge cases make this problem deceptively tricky. What started as a simple validation task turned into quite the learning adventure, taking me through four completely different approaches before landing on the cleanest solution.

The problem asks us to validate strings like "2", "-0.1", "4.", "2e10" while rejecting "abc", "1e", "99e2.5", and other invalid formats. Each approach I tried taught me something new about problem-solving, and I want to share that journey because there's a lot to learn from seeing how our thinking evolves when tackling tricky problems.

What Even Counts as a Valid Number?

Before we dive into my failed attempts and eventual success, let's get on the same page about what we're actually trying to solve. The valid number format needs to handle:

These should work:

  • Simple integers: "2", "0089"
  • Numbers with signs: "-0.1", "+3.14"
  • Decimal numbers: "4.", "-.9", ".1"
  • Numbers with exponents: "2e10", "-90E3", "3e+7"

These should not:

  • "abc" - obviously not a number
  • "1e" - exponent without digits
  • "99e2.5" - can't have decimal after exponent
  • "--6" - multiple signs don't make sense
  • "1a", "e3", "95a54e53" - mixing valid and invalid characters

First Attempt: The Regex Rabbit Hole

My first thought was pretty typical: "This looks like pattern matching, so let's whip up a regex!" I mean, regular expressions are supposed to be great at this kind of thing, right?

Here's what I came up with:

function isValidNumberNaive(s) {
  const pattern = /^\s*[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?\s*$/;
  return pattern.test(s);
}

You know what? This actually worked! It passed all my test cases on the first try. The pattern breaks down like this:

  • ^\s* - Skip leading whitespace
  • [+-]? - Optional plus or minus
  • (\d+\.?\d*|\.\d+) - The number part (either digits with optional decimal, OR decimal with digits)
  • ([eE][+-]?\d+)? - Optional exponent part
  • \s*$ - Skip trailing whitespace

But here's the thing that bothered me as soon as I wrote it - this regex is basically magical incantations. If someone asked me to modify it or debug why it wasn't working, I'd be completely lost trying to explain what each part does. Regex is great until it isn't, and when it breaks, debugging it feels like trying to read tea leaves.

Plus, what if the requirements change? What if I need to handle new edge cases? I'd be stuck with this cryptic pattern that I barely understand myself.

Performance Analysis: Regex Approach

Let me break down the complexity of this regex approach:

Time Complexity: O(n)

  • The regex engine needs to scan through the entire string to match the pattern
  • In the worst case, it examines every character once
  • Even though regex can have complex internal algorithms, for this pattern, it's essentially linear

Space Complexity: O(1)

  • The regex pattern itself is a constant size, regardless of input
  • The .test() method doesn't create additional data structures proportional to input size
  • However, regex engines may use internal backtracking which could theoretically use more space

The complexity looks good, but the real issue isn't performance - it's maintainability and debuggability. When your "O(n)" solution is impossible to understand and modify, the theoretical efficiency doesn't matter much.

Second Attempt: Step by Step Breakdown

So I decided to make the logic more explicit. Instead of one complex pattern, I would process the string piece by piece, checking each part systematically. This felt much more maintainable:

function isValidNumberBetter(s) {
  // Step 1: Skip leading whitespace
  let i = 0;
  while (i < s.length && s[i] === " ") i++;
  if (i >= s.length) return false;

  // Step 2: Check for optional sign
  if (s[i] === "+" || s[i] === "-") i++;

  let hasNumber = false;
  let hasDot = false;

  // Step 3: Parse digits before decimal
  while (i < s.length && /[0-9]/.test(s[i])) {
    hasNumber = true;
    i++;
  }

  // Step 4: Parse optional decimal part
  if (i < s.length && s[i] === ".") {
    hasDot = true;
    i++;
    while (i < s.length && /[0-9]/.test(s[i])) {
      hasNumber = true;
      i++;
    }
  }

  // Step 5: We need at least one digit
  if (!hasNumber) return false;

  // Step 6: Parse optional exponent
  if (i < s.length && (s[i] === "e" || s[i] === "E")) {
    i++;
    if (i < s.length && (s[i] === "+" || s[i] === "-")) i++;

    let exponentHasNumber = false;
    while (i < s.length && /[0-9]/.test(s[i])) {
      exponentHasNumber = true;
      i++;
    }

    if (!exponentHasNumber) return false;
  }

  // Step 7: Skip trailing whitespace
  while (i < s.length && s[i] === " ") i++;

  return i === s.length;
}

This approach felt much more readable! You can literally follow the logic step by step:

  1. Skip spaces
  2. Check for sign
  3. Parse the number part
  4. Parse the decimal part
  5. Parse the exponent part
  6. Make sure we processed everything

It worked great and passed all my tests. But I still felt like there was something clunky about it. The code felt a bit verbose, and I was juggling multiple index positions throughout the function. Each validation step felt somewhat separate and not very cohesive.

Performance Analysis: Step-by-Step Approach

Now let's analyze the complexity of this more explicit approach:

Time Complexity: O(n)

  • I use a single index i that moves from start to end of the string
  • Each character is examined exactly once
  • No nested loops that would multiply the work
  • The while loops are sequential, not nested

Space Complexity: O(1)

  • I only use a few boolean variables (hasNumber, hasDot) and integers (i)
  • These don't scale with input size - they're constant space
  • No arrays or data structures that grow with the input

This approach has the same theoretical complexity as the regex, but it's much more explicit about what it's doing. When I need to debug or modify the logic, I can see exactly what's happening at each step.

Third Attempt: State Machine Overthinking

Then I got this idea about using a state machine. I was reading about formal methods at the time, and I thought "Hey, this problem can be modeled as a state machine, right? Each character moves us to a new state, and some states are valid ending states."

So I built this elaborate state machine:

function isValidNumberOptimized(s) {
  // Define all possible states
  const State = {
    START: 0, // Initial state
    SIGN: 1, // Just read a sign
    INTEGER: 2, // Reading integer digits
    DOT: 3, // Just read a dot
    DECIMAL: 4, // Reading decimal digits
    EXPONENT: 5, // Just read exponent
    EXPONENT_SIGN: 6, // Reading exponent sign
    EXPONENT_NUMBER: 7, // Reading exponent digits
    END: 8, // Valid ending state
  };

  // Define state transitions
  const transitions = {
    [State.START]: {
      digit: State.INTEGER,
      "+": State.SIGN,
      "-": State.SIGN,
      ".": State.DOT,
      " ": State.START,
    },
    [State.SIGN]: {
      digit: State.INTEGER,
      ".": State.DOT,
    },
    [State.INTEGER]: {
      digit: State.INTEGER,
      ".": State.DECIMAL,
      e: State.EXPONENT,
      E: State.EXPONENT,
      " ": State.END,
    },
    [State.DOT]: {
      digit: State.DECIMAL,
      " ": State.END,
    },
    [State.DECIMAL]: {
      digit: State.DECIMAL,
      e: State.EXPONENT,
      E: State.EXPONENT,
      " ": State.END,
    },
    [State.EXPONENT]: {
      digit: State.EXPONENT_NUMBER,
      "+": State.EXPONENT_SIGN,
      "-": State.EXPONENT_SIGN,
      " ": State.END,
    },
    [State.EXPONENT_SIGN]: {
      digit: State.EXPONENT_NUMBER,
    },
    [State.EXPONENT_NUMBER]: {
      digit: State.EXPONENT_NUMBER,
      " ": State.END,
    },
    [State.END]: {
      " ": State.END,
    },
  };

  let currentState = State.START;

  // Process each character
  for (let char of s) {
    let charType = "";

    // Categorize the character
    if (char >= "0" && char <= "9") {
      charType = "digit";
    } else if (char === "+" || char === "-") {
      charType = char;
    } else if (char === ".") {
      charType = ".";
    } else if (char === "e" || char === "E") {
      charType = "e";
    } else if (char === " ") {
      charType = " ";
    } else {
      return false;
    }

    // Check if transition is valid
    if (!transitions[currentState] || !transitions[currentState][charType]) {
      return false;
    }

    // Move to next state
    currentState = transitions[currentState][charType];
  }

  // Check if we ended in a valid state
  return [
    State.INTEGER,
    State.DECIMAL,
    State.EXPONENT_NUMBER,
    State.END,
  ].includes(currentState);
}

This is mathematically elegant and very systematic. Each state represents exactly what we've seen so far, and transitions define what's allowed to come next.

But wow, was this approach a headache to implement correctly. I had several bugs in my first attempts, and I was only passing 39 out of 44 test cases initially. The transition table was cumbersome to maintain, and when something went wrong, it was honestly harder to debug than my previous attempts.

This really drove home a lesson I've learned before: just because a solution is mathematically elegant doesn't mean it's the most practical one.

Performance Analysis: State Machine Approach

Let me analyze the complexity of this state machine approach:

Time Complexity: O(n)

  • I process each character exactly once in the main for loop
  • Each character categorization and state transition is O(1)
  • The final state check with .includes() is O(1) since it's checking a fixed array of 4 states
  • Overall: n characters × O(1) operations = O(n)

Space Complexity: O(1)

  • The State object and transitions object are constant size (9 states, fixed number of transitions)
  • currentState is just a number
  • charType is a string, but its length is bounded (max "EXPONENT_NUMBER" which is constant)
  • Even though I create these objects once, they don't grow with input size

Trade-offs:

  • Pros: Very systematic, mathematically sound, easy to prove correctness
  • Cons: High constant factors due to object lookups, complex to implement correctly, hard to debug
  • When it's worth it: When you have complex validation rules that benefit from the formal structure

The theoretical complexity is the same as my previous approaches, but the practical performance might be worse due to the object property lookups and string operations.

Final Solution: The Aha Moment

Then it just clicked. I was totally overcomplicating things! What if I just used simple boolean flags to track what I'd seen so far? It seemed almost too simple, but sometimes the best solutions really are hiding in plain sight.

function isValidNumber(s) {
  // Remove leading and trailing whitespace
  s = s.trim();

  // Track what we've seen with simple boolean flags
  let seenDigit = false; // Have we seen any digits?
  let seenDot = false; // Have we seen a decimal point?
  let seenE = false; // Have we seen an exponent?
  let digitAfterE = true; // Do we have digits after the exponent?

  for (let i = 0; i < s.length; i++) {
    const currentChar = s[i];

    if (/[0-9]/.test(currentChar)) {
      // Found a digit
      seenDigit = true;
      if (seenE) {
        // If we're after an exponent, mark that we have digits
        digitAfterE = true;
      }
    } else if (currentChar === "+" || currentChar === "-") {
      // Sign can only appear at start or right after 'e'/'E'
      if (i > 0 && s[i - 1] !== "e" && s[i - 1] !== "E") {
        return false;
      }
    } else if (currentChar === ".") {
      // Can't have multiple dots or dots after exponent
      if (seenDot || seenE) return false;
      seenDot = true;
    } else if (currentChar === "e" || currentChar === "E") {
      // Can't have multiple exponents or exponent without digits
      if (seenE || !seenDigit) return false;
      seenE = true;
      digitAfterE = false; // Reset flag for exponent part
    } else {
      // Any other character is invalid
      return false;
    }
  }

  // Valid if: we saw digits AND (no exponent OR digits after exponent)
  return seenDigit && digitAfterE;
}

This is the winner! Here's why I absolutely love this approach:

It's crystal clear. Each flag has a specific meaning, and you can literally read the logic like English sentences.

It's efficient. O(n) time complexity, O(1) space complexity - optimal performance.

It handles all edge cases. All 44 test cases passed perfectly.

It's maintainable. If I needed to add new validation rules, it would be straightforward.

Performance Analysis: Boolean Flags Approach (Final Solution)

Let me break down why this final approach is optimal:

Time Complexity: O(n)

  • I iterate through each character exactly once with the for loop
  • All operations inside the loop are O(1): comparisons, assignments, boolean checks
  • The .trim() operation is O(n), but that's dominated by the main O(n) loop
  • Total: O(n) + O(n) = O(n)

Space Complexity: O(1)

  • I use exactly 4 boolean variables: seenDigit, seenDot, seenE, digitAfterE
  • Plus a few integer variables: i for the loop
  • The s.trim() creates a new string, but that's O(n) space and temporary
  • If I wanted to avoid the trim allocation, I could work with indices instead

Why this is optimal:

  1. Linear time: You can't do better than O(n) because you need to examine each character
  2. Constant space: You can't do better than O(1) space for this problem without additional constraints
  3. Low constant factors: Simple operations, no complex data structures or lookups

Complexity Comparison Summary

Here's how all my approaches compare:

ApproachTime ComplexitySpace ComplexityMaintainability
RegexO(n)O(1)Low
Step-by-stepO(n)O(1)Medium
State machineO(n)O(1)Low
Boolean flagsO(n)O(1)High

Key insight: All approaches have the same theoretical complexity, but they differ wildly in practical usability. The boolean flags approach achieves the same O(n) time and O(1) space with the highest maintainability.

Testing: Does It Actually Work?

Let me show you that this solution actually works by testing it with some examples:

const testCases = [
  { input: "0", expected: true },
  { input: "e", expected: false },
  { input: ".", expected: false },
  { input: " 0 ", expected: true },
  { input: " 0.1 ", expected: true },
  { input: "2e10", expected: true },
  { input: "abc", expected: false },
  { input: "1a", expected: false },
  { input: "1e", expected: false },
  { input: "99e2.5", expected: false },
];

testCases.forEach(({ input, expected }) => {
  const result = isValidNumber(input);
  console.log(
    `"${input}" -> ${result} (expected ${expected}) ${
      result === expected ? "✓" : "✗"
    }`
  );
});

Perfect! Every single test case passes. But let me walk through a few examples to show you exactly how the logic works:

Example 1: "2e10" (should be valid)

  • Start: seenDigit=false, seenDot=false, seenE=false, digitAfterE=true
  • '2' → seenDigit = true
  • 'e' → seenE = true, digitAfterE = false (reset for exponent)
  • '1' → seenDigit = true, digitAfterE = true (found digit after exponent)
  • '0' → seenDigit = true, digitAfterE = true
  • End: seenDigit=true AND digitAfterE=true → return true ✓

Example 2: "1e" (should be invalid)

  • Start: seenDigit=false, seenDot=false, seenE=false, digitAfterE=true
  • '1' → seenDigit = true
  • 'e' → seenE = true, digitAfterE = false (reset for exponent)
  • End: seenDigit=true AND digitAfterE=false → return false ✗

Example 3: "99e2.5" (should be invalid)

  • Start: seenDigit=false, seenDot=false, seenE=false, digitAfterE=true
  • '9' → seenDigit = true
  • '9' → seenDigit = true
  • 'e' → seenE = true, digitAfterE = false
  • '2' → seenDigit = true, digitAfterE = true
  • '.' → return false immediately (can't have decimal after exponent) ✗

Key Takeaways: What I Learned

This problem taught me so much more than just how to validate numbers. It reinforced some really important lessons about problem-solving that I think apply to coding in general:

  1. The first working solution isn't always the best solution. I had a working regex, but it was unmaintainable. The step-by-step approach was better, but still not ideal. Don't stop at "it works" - keep looking for better.

  2. Elegance doesn't always mean complexity. My state machine was mathematically beautiful but practically terrible. Sometimes the simplest approach really is the best one.

  3. Readability trumps cleverness. The boolean flags approach isn't the most sophisticated solution I've ever written, but it's the one I'd want to maintain and explain to other developers.

  4. Don't overthink it. I spent way too much time on complicated approaches when the answer was just simple boolean flags. Trust your first instincts sometimes.

  5. Debugging is easier with clear logic. When the boolean flag approach had bugs, they were easy to spot and fix compared to the regex or state machine approaches.

  6. Complexity analysis matters, but so does practical performance. All my approaches had the same theoretical complexity, but the boolean flags approach was fastest in practice due to lower constant factors.

  7. When Big O is the same, focus on implementation details. All my solutions were O(n) time and O(1) space, but they differed in constant factors, cache efficiency, and actual runtime performance.

  8. Understand the difference between theoretical and practical complexity. The regex had good theoretical complexity but poor practical performance due to engine overhead and backtracking.

The boolean flags approach might not win any beauty contests in computer science circles, but it's the solution I want to come back to six months from now. And at the end of the day, that's what good code is all about - code that works, code that's clear, and code that future you will thank you for writing.

Wrapping Up

This is one of those problems where the solution seems almost too simple once you see it. I spent way too long on the complicated approaches when the answer was just simple boolean flags.

The final solution is just 29 lines of code, super readable, and handles all cases perfectly. Sometimes the best solutions really are hiding in plain sight!

Try playing around with it yourself. Test different inputs, try to break it, see if you can think of edge cases I might have missed. Once you see the pattern of using boolean flags to track state, you'll start noticing opportunities to use this approach everywhere.

Remember: there's usually a sweet spot between too simple and overly complex, and that sweet spot often contains the most maintainable solution.

Keep coding, and don't forget - sometimes the most elegant solution is the simplest one!

Share:
Loading reactions...

Loading comments...