Learn/DSA Patterns
DSA PatternsStringsmedium11 min read

Character Frequency + Sorting

Count how often each character appears, then use that count to drive rearrangement — sorting characters by frequency, reorganising a string so no two adjacent characters are the same, or building the lexicographically smallest result. One frequency map, three fundamentally different problems.

#frequency#sorting#heap#bucket-sort#strings#greedy#beginner#interview
Table of contents

Before we start

You have seen frequency maps in Chapter 2 (Hashing). This chapter shows what happens after you build the map — when the goal isn't "find" but "rearrange." By the end you will be able to:

  • Sort characters by how often they appear, most frequent first.
  • Decide when a bucket sort is better than a heap for this family.
  • Recognise the "reorganise so no two adjacent are the same" variant and know exactly when it is impossible.

Read the story first. The code will make immediate sense once you have the picture.


Picture this first (no code yet)

A real-life story

It is election night. The tally room has a whiteboard with one column per candidate. Every vote that comes in adds one tally mark to the right column. At 11 pm, the announcer needs to read results "most votes first."

She doesn't sort the votes — she sorted the tallies. She looks at the whiteboard, reads the column heights, and announces the highest column first, writing out that candidate's name as many times as they got votes, then the second-highest, and so on.

That announcer is the character-frequency sorting algorithm: build the tally (frequency map), sort the tallies (not the characters), then write the output.


The actual problem

Sort Characters By Frequency (LC #451):

Given string s, return it rearranged so that characters appear in descending order of frequency.

Input:  "tree"
Output: "eert"   (or "eetr") — 'e' appears twice, 't' and 'r' once each

The naive approach would sort all characters directly — O(n log n). But we don't need character-level sorting at all. We only need to sort the frequency counts, of which there are at most 26 (for lowercase letters). That is the key efficiency insight.


First, the slow way (so you feel the pain)

Sort every character individually:

def sortByFrequency_slow(s: str) -> str:
    return "".join(sorted(s, key=lambda c: -s.count(c)))

For each character in the sorted call, s.count(c) scans the entire string — O(n) per character, O(n) total characters → O(n²) just for the keys. For n = 100 000 that is 10 billion operations. For a 26-character alphabet, we can do this in a single O(n) pass.


The turning point

Pause & think

You have counted every character's frequency. Now you want "most frequent first." Why is it wrong to just sort the characters (a-z) by their frequency value using a standard sort? And why does the answer change depending on whether you care about stability (same-frequency characters in any order) or a specific tie-breaking rule?

Think it through

Sorting characters by frequency is perfectly correct — it just costs O(26 log 26) = O(1) for the alphabet case, or O(k log k) where k is the number of distinct characters. The "slow" version was slow because it called s.count(c) inside sorted(), computing frequency from scratch for every character. Once you have the frequency map pre-built, sorting that is fast. Two separate steps: build O(n), sort O(k log k). Total: O(n).


The one idea to remember

The entire pattern in one sentence

Build a character frequency map in O(n), sort the distinct characters by their count in O(k log k), then write each character repeated by its count — producing the rearranged string in O(n) total.


Watch it happen, frame by frame

Input: "treeeeat"

Step 1 — Build frequency map:
  t → 1
  r → 1
  e → 4
  a → 1

Step 2 — Sort by frequency descending:
  [('e', 4), ('t', 1), ('r', 1), ('a', 1)]
  (ties broken arbitrarily — any order is valid)

Step 3 — Write output:
  'e' × 4"eeee"
  't' × 1"eeeeet"   ← wrong, I mean append 't'
  'r' × 1"eeeetr"
  'a' × 1"eeeeetra"

Output: "eeeetra"  ✅  (or any permutation of single-occurrence chars at the end)

The code, line by line

from collections import Counter
import heapq

def frequencySort(s: str) -> str:
    freq = Counter(s)                          # {'e':4, 't':1, 'r':1, 'a':1}

    # max-heap: Python has min-heap, so negate count
    heap = [(-count, char) for char, count in freq.items()]
    heapq.heapify(heap)                        # O(k) where k = distinct chars

    result = []
    while heap:
        neg_count, char = heapq.heappop(heap)  # most frequent first
        result.append(char * (-neg_count))     # repeat char by its count

    return "".join(result)
  • Counter(s) — one pass, O(n), builds {char: count}.
  • Negate counts for max-heap simulation — Python's heapq is a min-heap; negating turns smallest-negative (= largest count) into the highest priority.
  • char * (-neg_count) — Python string multiplication. 'e' * 4 = "eeee". One line, no loop.
  • "".join(result) — assembles the final string in O(n).

Alternative: bucket sort (O(n), no heap)

If you want O(n) strict (no log factor), use bucket sort: index by frequency.

def frequencySort_bucket(s: str) -> str:
    freq  = Counter(s)
    n     = len(s)
    # buckets[i] = list of chars that appear exactly i times
    buckets = [[] for _ in range(n + 1)]
    for char, count in freq.items():
        buckets[count].append(char)

    result = []
    for count in range(n, 0, -1):      # highest frequency first
        for char in buckets[count]:
            result.append(char * count)

    return "".join(result)

No sorting, no heap — just array indexing. O(n) total.


Variant: Reorganize String (LC #767)

Problem: rearrange characters so no two adjacent characters are the same. Return "" if impossible.

When is it impossible? If any character appears more than (n + 1) // 2 times, it cannot be spaced out — it must occupy more than half the positions, forcing two copies to be adjacent.

n = 5  →  max allowed = 3  (positions 0, 2, 4 for the dominant char)
"aaab"'a' appears 3 times, n=4, max = 2 → impossible → return ""
"aab"'a' appears 2 times, n=3, max = 2 → possible → "aba"

Greedy strategy: always place the most frequent remaining character next (as long as it isn't the same as the last character placed). Use a max-heap to always access the most frequent.

def reorganizeString(s: str) -> str:
    freq = Counter(s)
    n    = len(s)

    # Impossibility check
    if max(freq.values()) > (n + 1) // 2:
        return ""

    heap = [(-count, char) for char, count in freq.items()]
    heapq.heapify(heap)

    result = []
    prev_count, prev_char = 0, ""   # last placed character (don't repeat it)

    while heap:
        count, char = heapq.heappop(heap)   # most frequent available

        result.append(char)
        count += 1                           # count is negative; += 1 means one fewer

        # re-add previous character now that it's no longer "last placed"
        if prev_count < 0:
            heapq.heappush(heap, (prev_count, prev_char))

        prev_count, prev_char = count, char

    return "".join(result)

Frame-by-frame: "aab"

freq = {'a': 2, 'b': 1}   heap = [(-2,'a'), (-1,'b')]

Step 1: pop (-2,'a'). Place 'a'. count=-1. prev=(−1,'a').
        heap = [(-1,'b')]
        result = ['a']

Step 2: pop (-1,'b'). Place 'b'. count=0.
        Re-add prev (-1,'a') → heap = [(-1,'a')]
        prev=(0,'b').
        result = ['a','b']

Step 3: pop (-1,'a'). Place 'a'. count=0.
        Re-add prev (0,'b') → count==0, don't re-add.
        result = ['a','b','a']

Output: "aba"  ✅

Pause & think

In the reorganize heap loop, why do we re-add prev_char after popping the new character, rather than before checking availability? What would break if we pushed prev_char back before popping?

Answer

If we pushed prev_char back before popping, the heap might immediately return prev_char again (if it's still the most frequent). We'd place the same character twice in a row — exactly the constraint we're trying to avoid. By popping first and only then re-adding prev_char, we guarantee the new character is different from the previous one before prev_char even re-enters contention.


Heap vs bucket sort — when to use which

SituationUseWhy
Sort by frequency, return resultBucket sortO(n), simpler
Need "most frequent at each step" during constructionMax-heapO(log k) per step, handles changing counts
Reorganize / interleave greedilyMax-heapFrequencies change after each placement
"Top K frequent" questionsMin-heap of size KChapter 9.2

Where to spot this pattern

Trigger words:

  • "sort by frequency" or "most frequent first"
  • "rearrange so no two adjacent are the same"
  • "reorganize" + any constraint involving character repetition
  • "top K frequent characters" (frequency map + heap from Chapter 9.2)

5 disguises:

  1. Sort Characters By Frequency (LC #451): bucket sort or heap, output sorted by count.
  2. Reorganize String (LC #767): greedy max-heap, always place most frequent non-adjacent.
  3. Task Scheduler (LC #621): same greedy as reorganize but with cooldown period k; count idle slots.
  4. Rearrange String k Distance Apart (LC #358): reorganize with gap k instead of gap 1.
  5. Largest Number (LC #179): custom comparator sort — "which concatenation is bigger?" Not character frequency, but the same "sort by a derived key" skeleton.

Common traps

Watch out for these

  • Computing s.count(c) inside sorted(). This is O(n) per character → O(n²) total. Always pre-build the Counter first, then sort by the pre-computed values.
  • Forgetting the impossibility check for Reorganize String. If the most frequent character appears more than (n+1)//2 times, no valid arrangement exists. Return "" immediately — the heap loop will produce a wrong answer, not an error.
  • Using count == 0 to stop re-adding in Reorganize. In the heap version, once a character's count reaches 0 (negated: 0), it has been fully placed. Don't push it back. Check if prev_count < 0.
  • Confusing "sort by frequency" with "sort lexicographically within same frequency." LC #451 accepts any order among equal-frequency characters. Some variants specify tie-breaking — read the problem statement carefully.

Complexity

ProblemTimeSpace
Sort by Frequency (heap)O(n + k log k)O(n)
Sort by Frequency (bucket)O(n)O(n)
Reorganize StringO(n log k)O(k)

k = number of distinct characters (≤ 26 for lowercase).


Say it like a pro (interview one-liner)

"I'll build a frequency map in O(n), then use a max-heap to always place the most frequent remaining character — skipping it if it's the same as the last placed. For 'sort by frequency' without constraints I'd use bucket sort for strict O(n). Reorganize String adds the adjacent constraint, so I need the heap's O(log k) ordering at every step."


Remember this forever

Char Frequency + Sorting — two tools

Bucket sort (sort by frequency):

freq = Counter(s)
buckets = [[] for _ in range(len(s) + 1)]
for char, count in freq.items():
    buckets[count].append(char)
result = []
for i in range(len(s), 0, -1):
    for c in buckets[i]: result.append(c * i)

Greedy max-heap (reorganize / interleave):

if max(freq.values()) > (n+1)//2: return ""   # impossibility check first
heap = [(-cnt, c) for c, cnt in freq.items()]; heapq.heapify(heap)
# pop most frequent, place it, re-add previous char

Key trap: re-add prev_char AFTER popping new char, not before.


Check yourself

For "Sort Characters by Frequency," why is bucket sort strictly O(n) while the heap approach is O(n + k log k)?

Bucket sort uses array indexing — placing a character into buckets[count] is O(1), and reading back from n down to 1 is O(n). There is no comparison-based sorting at all. The heap approach requires heapifying k elements (O(k)) and then k pop operations each costing O(log k) — total O(k log k) extra on top of the O(n) frequency build. For k ≤ 26, this difference is negligible in practice, but bucket sort is the theoretically optimal approach.

Prove that if any character appears more than (n+1)//2 times, no valid reorganization exists.

Let the most frequent character appear f times. In any valid arrangement of n characters where no two identical characters are adjacent, the most frequent character must occupy alternating positions: 0, 2, 4, … The maximum number of positions available to a single character is ⌈n/2⌉ = (n+1)//2. If f > (n+1)//2, there are not enough non-adjacent slots — at least two copies must be adjacent. Therefore no valid arrangement exists. ∎

In the Reorganize String heap loop, what ensures we never place the same character twice in a row?

The prev_char mechanism. After placing a character, we store it as prev_char and remove it from consideration (we don't re-add it to the heap yet). On the next iteration, we pop a different character from the heap (the most frequent among the remaining characters, which cannot be prev_char). Only then do we re-add prev_char to the heap, making it available for future positions — but never the immediate next position.


Practice problems

ProblemDifficultyWhat to noticeLink
Sort Characters By FrequencyMediumBucket sort for O(n); heap for generalityLC #451
Reorganize StringMediumImpossibility check first; greedy max-heapLC #767
Task SchedulerMediumSame greedy as reorganize; count idle slotsLC #621
Top K Frequent ElementsMediumFrequency map + min-heap of size KLC #347

Next up: Parentheses Problems — when a stack (or a simple counter) turns bracket-matching and longest-valid-sequence problems into clean O(n) solutions.