Zigzag string transformation

AS
Ackshaey Singh
Difficulty Hard
Time Complexity
O(n)
Space Complexity
O(n)
String
Google Apple Uber Adobe Amazon Yahoo Oracle

You are fifteen minutes into a Google phone screen when the interviewer says, "I have a string and a number of rows. I want you to write it in a zigzag pattern and read it back." At first glance this sounds like a trick question, but it is actually a clean simulation problem that tests your ability to model a physical pattern in code. This problem, also known as "Zigzag Conversion" on other platforms, shows up regularly at Google, Apple, Uber, and Amazon.

TL;DR

Distribute characters across numRows string buffers by bouncing a row index up and down. When you hit the top or bottom row, toggle direction. After processing every character, concatenate all rows. This runs in O(n) time and O(n) space. The key insight is that you do not need to build a 2D grid. Just track which row each character belongs to and append it there.

Why This Problem Matters

Zigzag string transformation is a pattern simulation problem. You are not applying a well-known algorithm like binary search or BFS. Instead, you need to observe a visual pattern, figure out how it maps to row indices, and translate that observation into clean code. That skill, turning a visual or physical intuition into an implementation, is exactly what interviewers are testing. It also makes a solid warm-up before harder string problems like longest substring without repeating characters or edit distance.

Understanding the Problem

Given a string s and an integer numRows, arrange the characters of s in a zigzag pattern across the specified number of rows, then read the result row by row.

For example, "FIRECODEHIRING" with 3 rows:

F   C   H   N
I E O E I I G
R   D   R

Reading each row left to right produces "FCHNIEOEIIGRDR".

With 4 rows, "PAYPALISHIRING" becomes:

P     I    N
A   L S  I G
Y A   H R
P     I

Reading row by row: "PINALSIGYAHRPI".

Prefer a different language? Jump to solutions in other languages.

Edge Cases

Before diving into the algorithm, let's identify the cases where no real work is needed:

  1. numRows == 1: Every character stays on the same row. Return s unchanged.
  2. s.length()numRows: Each character lands on its own row, which reads back in original order. Return s.
  3. numRows == 2: Characters alternate between two rows, producing a simple interleaving.

Handling the first two with an early return keeps the main loop clean.

The Zigzag Pattern

Look at the 3-row example more carefully. The row index for each character follows this sequence:

Character: F  I  R  E  C  O  D  E  H  I  R  I  N  G
Row:       0  1  2  1  0  1  2  1  0  1  2  1  0  1

The row index goes down from 0 to 2, then bounces back up to 0, then down again. It is a simple oscillation between 0 and numRows - 1.

Here is how the direction toggle works for 3 rows. The highlighted nodes show where the direction flips:

Loading visualization...

At row 0, goingDown flips to true. At row 2 (the last row), it flips to false. This bouncing behavior is the entire algorithm.

Solution Approach

The strategy is straightforward:

  1. Create an array of numRows string buffers (one per row).
  2. Walk through each character in s, appending it to the buffer for the current row.
  3. When the current row hits the top (0) or bottom (numRows - 1), toggle the direction.
  4. After processing all characters, concatenate the buffers in order.

No 2D array needed. No index math. Just a row counter that bounces.

Tracing Through "FIRECODEHIRING" with 3 Rows

Let's watch the row buffers fill up step by step:

Loading visualization...

After all 14 characters are distributed, the rows contain "FCHN", "IEOEIG", and "RDR". Concatenating them gives "FCHNIEOEIIGRDR".

Tracing with 4 Rows

The same algorithm works with more rows. Here is "PAYPALISHIRING" distributed across 4 rows:

Loading visualization...

The final result is "PIN" + "ALSIG" + "YAHR" + "PI" = "PINALSIGYAHRPI".

Implementation

public class Solution {
  public String convert(String s, int numRows) {
    // Early return for trivial cases
    if (numRows == 1 || s.length() <= numRows) {
      return s;
    }

    // Create a StringBuilder for each row
    StringBuilder[] rows = new StringBuilder[numRows];
    for (int i = 0; i < numRows; i++) {
      rows[i] = new StringBuilder();
    }

    int currentRow = 0;
    boolean goingDown = false;

    // Distribute each character to the appropriate row
    for (char c : s.toCharArray()) {
      rows[currentRow].append(c);

      // Toggle direction at the top and bottom rows
      if (currentRow == 0 || currentRow == numRows - 1) {
        goingDown = !goingDown;
      }

      // Move to the next row
      currentRow += goingDown ? 1 : -1;
    }

    // Concatenate all rows
    StringBuilder result = new StringBuilder();
    for (StringBuilder row : rows) {
      result.append(row);
    }
    return result.toString();
  }
}

Walk through the code:

  1. Early exit: If there is only one row or the string is too short to zigzag, return immediately.
  2. Row buffers: An array of StringBuilder objects, one per row.
  3. Direction toggle: goingDown starts as false. On the very first iteration, currentRow == 0 triggers a flip to true, so we start moving downward. When we reach the bottom row, it flips back to false.
  4. Row movement: currentRow increments by 1 when going down, decrements by 1 when going up.
  5. Final assembly: Append each row's content to the result.

The direction toggle is worth dwelling on. Notice that goingDown starts false, not true. The toggle happens before the row index moves. So on the first character (row 0), we flip to true and then move down to row 1. This handles the starting condition without a special case.

Complexity Analysis

Time Complexity: O(n)

Each of the n characters is visited exactly once. Appending to a StringBuilder is amortized O(1). The final concatenation of all rows is also O(n) total since the combined length of all rows equals n.

Space Complexity: O(n)

The StringBuilder array stores all n characters across its rows. The result string is another O(n). Total auxiliary space is O(n).

The Mathematical Alternative

There is another approach that computes the output directly using cycle lengths. Each zigzag cycle has length 2 * numRows - 2. For each row, you can calculate which indices from the original string belong to that row using modular arithmetic.

While this avoids the row buffers, it is harder to implement correctly under interview pressure. The cycle-based approach requires handling diagonal characters separately from the main cycle positions, which introduces more opportunities for off-by-one errors. Both approaches have identical time and space complexity, so the simulation approach is the practical choice for interviews.

Common Mistakes

  1. Initializing goingDown to true: This causes an off-by-one error where the first character ends up on row 1 instead of row 0. Starting with false and letting the toggle at row 0 handle it is cleaner.

  2. Forgetting the early return: Without the numRows == 1 check, the toggle logic breaks because currentRow will always be 0 and the direction will flip every single iteration.

  3. Using a 2D char array: Some candidates allocate a numRows x n grid with lots of empty cells. This wastes space and complicates the reading step. The row-buffer approach avoids all of that.

  4. Off-by-one in direction check: The direction should toggle when currentRow == 0 or currentRow == numRows - 1, not when it would exceed those bounds. Checking after appending (not before) makes the code straightforward.

Interview Tips

When you get this problem in an interview:

  1. Draw the zigzag first. Write out a small example on the whiteboard. The pattern becomes obvious once you see the row indices oscillating.

  2. State the edge cases early. Mentioning numRows == 1 and short strings upfront shows thoroughness.

  3. Explain the toggle clearly. "I maintain a boolean that flips at the boundaries" is a crisp one-sentence explanation that interviewers appreciate.

  4. Mention the math approach as a follow-up. If the interviewer asks about alternatives, describe the cycle-length formula: cycleLen = 2 * numRows - 2, with characters at indices j and cycleLen - j for each row j. You do not need to implement it, just knowing it exists demonstrates depth.

Key Takeaways

  • The zigzag pattern is just a row index bouncing between 0 and numRows - 1. Model it with a direction boolean that toggles at the boundaries.
  • Use an array of string buffers (one per row) instead of a 2D grid. This keeps the implementation clean and avoids wasted space.
  • Always handle numRows == 1 and short strings with an early return. These edge cases break the toggle logic if left unchecked.
  • Both the simulation and mathematical approaches run in O(n) time and O(n) space. The simulation is easier to implement correctly under pressure.
  • Starting goingDown as false and relying on the toggle at row 0 is the cleanest way to handle the initial direction without a special case.

Solutions in Other Languages

Python

class Solution:
    def convert(self, s: str, num_rows: int) -> str:
        if num_rows == 1 or num_rows >= len(s):
            return s

        rows = [''] * num_rows
        current_row = 0
        going_down = False

        for char in s:
            rows[current_row] += char
            if current_row == 0 or current_row == num_rows - 1:
                going_down = not going_down
            current_row += 1 if going_down else -1

        return ''.join(rows)

JavaScript

class Solution {
  convert(s, numRows) {
    if (numRows === 1 || s.length <= numRows) {
      return s;
    }

    const rows = Array.from({ length: Math.min(numRows, s.length) }, () => "");
    let currentRow = 0;
    let goingDown = false;

    for (let char of s) {
      rows[currentRow] += char;
      if (currentRow === 0 || currentRow === numRows - 1) {
        goingDown = !goingDown;
      }
      currentRow += goingDown ? 1 : -1;
    }

    return rows.join("");
  }
}

TypeScript

class Solution {
  convert(s: string, numRows: number): string {
    if (numRows === 1 || s.length <= numRows) {
      return s;
    }

    const rows: string[] = Array.from({ length: Math.min(numRows, s.length) }, () => "");
    let currentRow = 0;
    let goingDown = false;

    for (const char of s) {
      rows[currentRow] += char;
      if (currentRow === 0 || currentRow === numRows - 1) {
        goingDown = !goingDown;
      }
      currentRow += goingDown ? 1 : -1;
    }

    return rows.join("");
  }
}

C++

#include <string>
#include <sstream>
#include <vector>

class Solution {
public:
  std::string convert(std::string &s, int numRows) {
    if (numRows == 1 || numRows >= s.size()) {
      return s;
    }

    std::vector<std::string> rows(std::min(numRows, int(s.size())));
    int currentRow = 0;
    bool goingDown = false;

    for (char c : s) {
      rows[currentRow] += c;
      if (currentRow == 0 || currentRow == numRows - 1) {
        goingDown = !goingDown;
      }
      currentRow += goingDown ? 1 : -1;
    }

    std::string result;
    for (const std::string &row : rows) {
      result += row;
    }
    return result;
  }
};

Scala

class Solution {
  def convert(s: String, numRows: Int): String = {
    if (numRows == 1 || s.length <= numRows) return s

    val rows = Array.fill(math.min(numRows, s.length))("")
    var currentRow = 0
    var goingDown = false

    for (c <- s) {
      rows(currentRow) += c
      if (currentRow == 0 || currentRow == numRows - 1) goingDown = !goingDown
      currentRow += (if (goingDown) 1 else -1)
    }

    rows.mkString
  }
}

Kotlin

class Solution {
  fun convert(s: String, numRows: Int): String {
    if (numRows == 1 || s.length <= numRows) {
      return s
    }

    val rows = Array(numRows) { StringBuilder() }
    var currentRow = 0
    var goingDown = false

    s.forEach { c ->
      rows[currentRow].append(c)
      if (currentRow == 0 || currentRow == numRows - 1) {
        goingDown = !goingDown
      }
      currentRow += if (goingDown) 1 else -1
    }

    return rows.joinToString("") { it.toString() }
  }
}

Swift

import Foundation

class Solution {
    func convert(_ s: String, _ numRows: Int) -> String {
        if numRows == 1 || s.count <= numRows {
            return s
        }

        var rows = [String](repeating: "", count: numRows)
        var currentRow = 0
        var goingDown = false

        for c in s {
            rows[currentRow].append(c)
            if currentRow == 0 || currentRow == numRows - 1 {
                goingDown = !goingDown
            }
            currentRow += goingDown ? 1 : -1
        }

        return rows.joined()
    }
}

Ruby

class Solution
  def convert(s, num_rows)
    return s if num_rows == 1 || s.length <= num_rows

    rows = Array.new(num_rows) { '' }
    current_row = 0
    going_down = false

    s.each_char do |c|
      rows[current_row] += c
      going_down = !going_down if current_row == 0 || current_row == num_rows - 1
      current_row += going_down ? 1 : -1
    end

    rows.join
  end
end

Rust

pub struct Solution;

impl Solution {
    pub fn convert(&self, s: String, num_rows: i32) -> String {
        if num_rows == 1 || s.len() <= num_rows as usize {
            return s;
        }

        let mut rows: Vec<String> = vec![String::new(); num_rows as usize];
        let mut current_row: i32 = 0;
        let mut going_down = false;

        for c in s.chars() {
            rows[current_row as usize].push(c);
            if current_row == 0 || current_row == num_rows - 1 {
                going_down = !going_down;
            }
            current_row += if going_down { 1 } else { -1 };
        }

        rows.concat()
    }
}

Dart

class Solution {
  String convert(String s, int numRows) {
    if (numRows == 1 || s.length <= numRows) {
      return s;
    }

    List<StringBuffer> rows = List.generate(numRows, (_) => StringBuffer());
    int currentRow = 0;
    bool goingDown = false;

    for (String c in s.split('')) {
      rows[currentRow].write(c);
      if (currentRow == 0 || currentRow == numRows - 1) {
        goingDown = !goingDown;
      }
      currentRow += goingDown ? 1 : -1;
    }

    return rows.map((sb) => sb.toString()).join();
  }
}

C#

using System.Text;

public class Solution {
    public string Convert(string s, int numRows) {
        if (numRows == 1 || s.Length <= numRows) {
            return s;
        }

        var rows = new StringBuilder[numRows];
        for (int i = 0; i < numRows; i++) {
            rows[i] = new StringBuilder();
        }

        int currentRow = 0;
        bool goingDown = false;

        foreach (char c in s) {
            rows[currentRow].Append(c);
            if (currentRow == 0 || currentRow == numRows - 1) {
                goingDown = !goingDown;
            }
            currentRow += goingDown ? 1 : -1;
        }

        var result = new StringBuilder();
        foreach (var row in rows) {
            result.Append(row);
        }
        return result.ToString();
    }
}

PHP

class Solution {
    public function convert(string $s, int $numRows): string {
        if ($numRows === 1 || strlen($s) <= $numRows) {
            return $s;
        }

        $rows = array_fill(0, $numRows, '');
        $currentRow = 0;
        $goingDown = false;

        for ($i = 0; $i < strlen($s); $i++) {
            $rows[$currentRow] .= $s[$i];
            if ($currentRow === 0 || $currentRow === $numRows - 1) {
                $goingDown = !$goingDown;
            }
            $currentRow += $goingDown ? 1 : -1;
        }

        return implode('', $rows);
    }
}

Related Problems

Once you are comfortable with zigzag conversion, try these related string and simulation problems to broaden your skills:

  • Reverse a string in-place
  • Longest palindromic substring
  • String rotation check
  • Spiral matrix traversal (another "read pattern from grid" problem)

Consistent practice turns problems like this from tricky to routine. This problem and hundreds of others are available on Firecode, where over 50,000 engineers have prepared for technical interviews and landed offers at top companies. Whether you are targeting Google, Apple, or your next startup role, building fluency with simulation and string problems will pay dividends.

Frequently Asked Questions

What is the zigzag conversion problem?
The zigzag conversion problem asks you to take a string and rearrange its characters into a zigzag pattern across a given number of rows, then read the characters row by row to produce a new string. For example, 'PAYPALISHIRING' with 3 rows becomes 'PAHNAPLSIIGYIR' because the characters are distributed in a zigzag shape and then concatenated by row.
What is the time complexity of the zigzag conversion?
The time complexity is O(n) where n is the length of the input string. Each character is visited exactly once during distribution into rows, and concatenating all rows also takes O(n) time total. No character is processed more than once.
What is the space complexity of the zigzag conversion?
The space complexity is O(n) where n is the length of the input string. The algorithm creates an array of StringBuilders (or strings) that collectively hold all n characters. The output string also requires O(n) space.
How does the direction toggle work in the zigzag algorithm?
A boolean flag called goingDown tracks whether we are moving down or up through the rows. When the current row reaches 0 (top) or numRows - 1 (bottom), the flag is toggled. This creates the bouncing zigzag pattern where characters alternate between moving downward and upward through the rows.
What are the edge cases for zigzag conversion?
The main edge cases are: numRows equals 1 (return the string unchanged since there is no zigzag), the string length is less than or equal to numRows (each character goes to its own row, so the string is unchanged), and numRows equals 2 (characters alternate between two rows). Handling the first two with an early return simplifies the main logic.
Why is the row simulation approach preferred over the mathematical index approach?
The row simulation approach using StringBuilder arrays is easier to understand and implement correctly in interviews. The mathematical approach calculates cycle lengths and diagonal offsets, which is harder to derive under pressure and more error-prone. Both achieve O(n) time and O(n) space, so the simulation approach is the practical interview choice.
How is zigzag conversion different from a simple string reversal?
String reversal simply flips the character order. Zigzag conversion redistributes characters across rows in a specific pattern, then reads them back row by row. The output preserves the original character ordering within each row but interleaves characters from different positions in the original string. It is a rearrangement, not a reversal.
What is the cycle length in a zigzag pattern?
For numRows rows, one complete zigzag cycle has length 2 * numRows - 2. This accounts for numRows characters going down and numRows - 2 characters going back up (excluding the top and bottom rows which are shared between directions). For 4 rows the cycle length is 6, and for 3 rows it is 4.
Can the zigzag conversion be done in-place?
Not practically. The zigzag transformation rearranges characters in a non-trivial pattern that depends on the number of rows. Computing the destination index for each character requires either the simulation approach or the mathematical formula, both of which need auxiliary storage for the row buffers or output string.
How often does the zigzag conversion problem appear in coding interviews?
Zigzag conversion appears frequently at companies like Google, Apple, Uber, and Amazon. It tests string manipulation skills and the ability to simulate a pattern. While the problem itself is medium difficulty, interviewers value candidates who handle edge cases cleanly and explain the bouncing row index clearly.