DSA

Part 12 — Hash Tables

A practical, interview-focused guide to hash tables in Python, covering hashing, collisions, frequency counting, anagrams, caches, range and subarray problems, streaming patterns, memoization, and Staff-level trade-offs.

Deepak Mishra25 min read


Hash tables are one of the most powerful tools in coding interviews.

They can turn a repeated search from:

O(n)

into an expected:

O(1)

That single transformation is responsible for a huge number of interview solutions.

But the real skill is not knowing that Python has dict and set.

The real skill is recognizing:

When should I trade memory for faster lookup?

This part focuses on that decision.

The source material describes hash tables as structures that store keys and optionally associated values, with average constant-time insertion, deletion, and lookup when hashing is well behaved. It also covers collisions, load factor, rehashing, hash-function design, and a broad set of interview applications. citeturn0search4turn0search2


1. What Part 12 Is Really Teaching

Don’t approach hash-table problems as:

“Use a dictionary.”

Instead ask:

What information am I repeatedly looking for?
              |
              v
Can I store that information?
              |
              v
Can I index it by a key?
              |
              v
Can a hash table give me fast access?

The central transformation is:

Repeated search

Store information

Choose a key

Hash table

Expected O(1) lookup

That is the pattern behind many seemingly unrelated problems.


2. Python’s Hash-Table Tools

Python gives us two primary hash-based structures.

Dictionary

data = {}

A dictionary stores:

key → value

Example:

ages = {
    "Alice": 30,
    "Bob": 35,
}

Lookup:

ages["Alice"]

Set

seen = set()

A set stores unique values.

Example:

seen = {2, 5, 8}

if 5 in seen:
    print("found")

Use a set when you primarily need:

membership
deduplication

Use a dictionary when you need:

key → associated information

3. Expected Complexity

For a well-designed hash table, typical operations are expected to be:

Operation Average Worst case
Insert O(1) O(n)
Lookup O(1) O(n)
Delete O(1) O(n)

The expected constant-time behavior depends on good hashing and reasonable distribution of keys. The source explicitly notes that poor collision behavior can degrade performance. citeturn0search4

This is why a strong interview answer says:

Expected O(1)

rather than:

Always O(1)


4. How Hashing Works

Conceptually:

key
 |
 v
hash function
 |
 v
integer hash code
 |
 v
array index
 |
 v
storage location

For example:

"Deepak"

hash("Deepak")

some integer

index in table

The hash function maps a key to a hash code.

A fundamental requirement is:

equal keys

equal hash codes

A good hash function should also distribute keys reasonably uniformly so that many keys do not repeatedly map to the same location. citeturn0search2turn0search4


5. Collisions

Two different keys can produce the same table location.

For example:

key A ──┐
        ├──> same slot
key B ──┘

This is a:

collision

Collisions are unavoidable in a finite table when the key space is sufficiently large.

The important question is:

How does the implementation resolve them?


6. Collision Resolution

Two common strategies are:

Separate chaining

Each slot maintains a collection of entries.

slot 0 → []
slot 1 → [(A, value)]
slot 2 → [(B, value), (C, value)]
slot 3 → []

If two keys collide, both can live in the same bucket.


Open addressing

All entries remain inside the table.

When a slot is occupied, the implementation searches for another location according to a probing strategy.

Conceptually:

hash(key)

slot occupied

probe another slot

probe again

free slot

The exact implementation details differ, but the interview lesson is the same:

Hashing provides the initial location; collision handling determines what happens when multiple keys want that location.


7. Load Factor

The load factor is commonly represented as:

α = n / m

where:

  • n = number of stored entries
  • m = number of table slots

As the table becomes crowded:

load factor ↑

collisions ↑

lookup cost can increase

This is why hash tables periodically resize.


8. Rehashing

When the table becomes too full:

old table

allocate larger table

recompute positions

move entries

This operation is expensive because many entries must be relocated.

The source notes that rehashing can take O(n + m) time, but if it happens infrequently, the cost can be amortized over many operations. citeturn0search4

This leads to an important distinction:

individual resize

expensive

amortized sequence of inserts

efficient

9. Hashable Keys in Python

Python dictionary keys must be hashable.

Common examples:

int
str
float
tuple
frozenset

Mutable containers such as:

list
dict
set

cannot normally be used as dictionary keys.

Why?

Because a key’s hash must remain stable while it is stored.

Imagine:

insert key

hash(key) = H1

store at location H1

mutate key

hash(key) = H2

Now the object may no longer be discoverable at the location where it was stored.

This is why the source emphasizes avoiding mutable objects as hash-table keys. citeturn0search1


10. The Most Important Hash-Table Pattern

Suppose we repeatedly ask:

Have I seen X before?

A brute-force solution scans previous elements.

That can lead to:

O(n²)

Instead:

seen = set()

for x in nums:
    if x in seen:
        ...
    seen.add(x)

Now:

Time: O(n) expected
Space: O(n)

This is the basic transformation behind many interview problems.


11. Frequency Counting

A dictionary is also excellent for counting.

Example:

from collections import Counter

counts = Counter("banana")
print(counts)

Conceptually:

b → 1
a → 3
n → 2

Or manually:

counts = {}

for ch in text:
    counts[ch] = counts.get(ch, 0) + 1

Frequency counting is one of the most reusable hash-table patterns.


12. Palindromic Permutations

A string can be rearranged into a palindrome only under a specific frequency condition.

For an even-length string:

all character counts must be even

For an odd-length string:

at most one character can have an odd count

So instead of generating permutations, count characters.

from collections import Counter

def can_form_palindrome(s):
    odd_count = sum(
        count % 2
        for count in Counter(s).values()
    )

    return odd_count <= 1

Complexity:

Time:  O(n)
Space: O(k)

where k is the number of distinct characters.

The source lists testing whether a string can be permuted into a palindrome as a core hash-table application. citeturn0search2


13. Anagrams

Consider:

listen
silent
enlist

All contain the same characters with the same frequencies.

One approach is to use the sorted word as a key:

key = ''.join(sorted(word))

Then:

listen → eilnst
silent → eilnst
enlist → eilnst

All three map to the same hash-table bucket.


14. Grouping Anagrams

from collections import defaultdict

def group_anagrams(words):
    groups = defaultdict(list)

    for word in words:
        key = ''.join(sorted(word))
        groups[key].append(word)

    return list(groups.values())

The conceptual pattern is:

object

canonical representation

hash-table key

group equivalent objects

This is a very important technique.


15. Choosing a Better Anagram Key

Sorting each word costs:

O(L log L)

where L is the word length.

If the alphabet is small, a frequency tuple can be used instead:

from collections import defaultdict

def group_anagrams(words):
    groups = defaultdict(list)

    for word in words:
        counts = [0] * 26

        for ch in word:
            counts[ord(ch) - ord('a')] += 1

        groups[tuple(counts)].append(word)

    return list(groups.values())

Now the key is based on character frequencies.

This illustrates a Staff-level trade-off:

Canonical sorting

simple
but
O(L log L)

Frequency signature

more specialized
but
O(L)

16. Anonymous Letter Construction

Suppose we have:

letter_text
magazine_text

and need to determine whether the letter can be constructed from the magazine characters.

A brute-force approach repeatedly searches for characters.

A better approach is frequency counting.

from collections import Counter

def constructible(letter, magazine):
    required = Counter(letter)

    for ch in magazine:
        if ch in required:
            required[ch] -= 1

            if required[ch] == 0:
                del required[ch]

    return not required

Complexity:

Time:  O(n + m)
Space: O(k)

The source specifically identifies anonymous-letter construction as a hash-table problem based on character frequencies. citeturn0search1turn0search2


17. The General Frequency-Deficit Pattern

The anonymous-letter problem teaches a reusable technique:

required counts

scan available resources

decrement counts

remove satisfied requirements

check whether anything remains

This appears in:

  • inventory matching
  • resource allocation
  • character construction
  • multiset comparison
  • token availability
  • demand/supply matching

18. ISBN Cache

Caching is one of the most important practical applications of hash tables.

Suppose we repeatedly query book information using an ISBN:

ISBN

lookup

book information

A hash table gives:

cache[isbn] = book

and expected:

O(1)

lookup.

The source includes implementing an ISBN cache as one of the core Chapter 12 problems. citeturn0search0


19. Why Caching Works

Without a cache:

request

expensive computation / I/O

result

With a cache:

request

hash lookup
   |
   +── hit ──> return immediately
   |
   +── miss ──> perform expensive operation

                  store

This is the classic:

compute once
reuse many times

pattern.


20. LRU Cache

A cache becomes more interesting when it has limited capacity.

Suppose:

capacity = 3

and we store:

A B C

If D arrives, one entry must be evicted.

An LRU cache removes:

Least Recently Used

The standard design combines:

Hash table
+
Doubly linked list

The hash table provides:

key → node

The linked list maintains:

most recently used

...

least recently used

This combination supports expected constant-time lookup and recency updates.


21. LRU Cache Architecture

                 Hash Table
              key → list node
                   |
                   v
        +-----------------------+
        |                       |
      HEAD                    TAIL
        |                       |
        v                       v
     newest → ... → oldest

Operations:

Lookup

dict[key]

Expected:

O(1)

Move to front

Doubly linked list:

O(1)

Evict oldest

Remove the tail node:

O(1)

This is a classic example of combining data structures because no single structure provides all required operations efficiently.


22. Hash Tables and LCA Optimization

Hash tables can also improve tree algorithms.

For example, when computing the lowest common ancestor of nodes with parent pointers, one approach is:

walk ancestors of node A
store them in a set

Then:

walk ancestors of node B
stop at first ancestor in the set

Conceptually:

ancestors = set()

node = a
while node:
    ancestors.add(node)
    node = node.parent

node = b
while node:
    if node in ancestors:
        return node
    node = node.parent

Complexity:

Time:  O(h)
Space: O(h)

where h is the relevant ancestor-chain length.

The source lists an LCA problem that explicitly optimizes for close ancestors among the hash-table applications. citeturn0search0


23. Most Frequent Queries

Suppose we receive queries:

A
B
A
C
A
B

We want the most frequent values.

Use:

from collections import Counter

counts = Counter(queries)

Then combine the frequency table with:

sorting

or:

heap

depending on how many results are required.

This is an important cross-chapter connection:

Hash table

frequency counting

Heap

Top K

24. Nearest Repeated Entry

Suppose:

A = [
    "a",
    "b",
    "c",
    "a",
    "d",
    "b"
]

We want the closest pair of equal entries.

The key observation:

We only need the most recent position of each value.

Maintain:

last_seen = {}

When processing index i:

if value already seen:
    distance = i - last_seen[value]

Then update:

last_seen[value] = i

25. Python

def closest_repeated_distance(items):
    last_seen = {}
    best = float("inf")

    for i, value in enumerate(items):
        if value in last_seen:
            best = min(best, i - last_seen[value])

        last_seen[value] = i

    return -1 if best == float("inf") else best

Complexity:

Time:  O(n)
Space: O(k)

This is a classic example of replacing repeated searching with remembered state.


26. Smallest Subarray Covering All Values

Suppose we have:

A = [a, b, c, a, d, b, c]

and need the shortest contiguous subarray containing:

{a, b, c}

This combines two patterns:

Hash table
+
Sliding window

Maintain:

frequency of values in current window

Then:

expand right

window becomes valid

shrink left

record minimum

The hash table provides fast frequency updates and membership information.


27. Sliding Window + Hash Table

General structure:

left = 0

for right in range(n):

    add A[right]

    while window satisfies condition:

        update answer

        remove A[left]
        left += 1

This pattern appears repeatedly in interview problems involving:

  • distinct values
  • character frequencies
  • coverage
  • repeated values
  • constraints over contiguous ranges

28. Smallest Sequentially Covering Subarray

A harder variation requires values to appear in a specified order.

For example:

target = [b, c, d]

We need a subarray containing:

b → c → d

in sequence.

The key idea is to track the best partial match for each target position.

Hash tables can store:

target value → relevant state

The important lesson is:

Hash tables do not have to store raw values. They can store algorithmic state associated with values.


29. Longest Subarray with Distinct Entries

We want the longest contiguous region containing no duplicate values.

Example:

[1, 2, 3, 1, 4, 5]

The answer is:

[2, 3, 1, 4, 5]

The standard pattern is:

sliding window
+
last-seen index

Maintain:

last_seen[value] = latest index

When a duplicate appears:

move left past previous occurrence

30. Python

def longest_distinct_subarray(nums):
    last_seen = {}
    left = 0
    best = 0

    for right, value in enumerate(nums):
        if value in last_seen:
            left = max(left, last_seen[value] + 1)

        last_seen[value] = right
        best = max(best, right - left + 1)

    return best

Complexity:

Time:  O(n)
Space: O(k)

where k is the number of distinct values in the active window.


31. Longest Contained Interval

Suppose:

A = [3, 5, 2, 1, 4, 8, 9]

We want the longest interval of consecutive integers contained in the set.

For example:

1, 2, 3, 4, 5

has length:

5

A useful approach is:

values = set(A)

Then for each value, explore only when it appears to be the beginning of an interval.


32. Avoid Repeated Work

The naive approach might start a scan from every value.

That can repeatedly traverse the same interval.

Instead:

if x - 1 not in values:
    x is a potential interval start

Then count:

x
x + 1
x + 2
...

Each value is effectively processed as part of an interval.

This produces expected linear-time behavior.

The deeper lesson is:

Use the hash table to detect where exploration should begin, so you don’t repeat work.


33. Top Three Scores Per Student

Suppose we receive:

(student, score)

records and need each student’s average of their top three scores.

A dictionary can map:

student → top scores

For small fixed k, maintain only the required scores.

For example:

from collections import defaultdict

scores = defaultdict(list)

for student, score in records:
    scores[student].append(score)

Then:

for student in scores:
    scores[student].sort(reverse=True)

For very large streams, a small heap per student can reduce memory.

This again demonstrates:

Hash table
+
Heap

as a combined design.


34. String Decomposition

Suppose a sentence is formed by concatenating words from a dictionary.

We may need to find all substrings corresponding to a specific collection of words.

Hash tables can represent:

word → required frequency

and:

word → current frequency

Then a sliding window can determine whether the current region satisfies the required multiset.

This is another combination:

Hashing
+
Frequency counting
+
Sliding window

35. Collatz Conjecture and Memoization

The Collatz sequence repeatedly applies:

if n is even:
    n = n / 2
else:
    n = 3n + 1

A naive implementation repeatedly recomputes sequences that converge to previously seen values.

Memoization changes this.

Store:

n → whether its sequence reaches 1

or:

n → known sequence information

Then when a previously solved value appears:

reuse the cached result

This is the general memoization pattern:

expensive recursive computation

cache state

reuse previously computed result

36. Hash Tables as Memoization

A hash table is effectively a generic memoization engine.

cache = {}

def solve(state):
    if state in cache:
        return cache[state]

    result = expensive_computation(state)

    cache[state] = result
    return result

The most important question is:

What should the cache key contain?

A good key must uniquely identify the state relevant to the computation.


37. Hashing Chess Positions

A chess position can contain many pieces and state variables.

A naive representation may require hashing a large object repeatedly.

A more sophisticated technique is an incremental hash.

A common conceptual approach uses random values for:

piece
square
state

and combines them using XOR.

When a piece moves:

remove old contribution
+
add new contribution

Instead of rebuilding the entire position representation.

Conceptually:

old hash
   XOR old piece-square value
   XOR new piece-square value

new hash

This is a powerful example of incremental state maintenance.

The source includes implementing a hash function for chess as an advanced hash-table application. citeturn0search2turn0search5


38. Hash Function Design

A good hash function should satisfy two major goals.

Requirement 1 — Equal objects must hash equally

a == b

hash(a) == hash(b)

This is mandatory.


Requirement 2 — Distribute unequal objects well

Ideally:

different keys

well-distributed hash codes

Poor distribution produces:

many collisions

longer lookup paths

performance degradation

The source emphasizes both correctness of equal-key hashing and good distribution. citeturn0search2turn0search4


39. Hash Table vs Array

Array

Good when:

integer index is naturally available

Example:

counts[character_code]

Advantages:

  • compact
  • predictable
  • excellent locality

Hash table

Good when:

keys are arbitrary

Example:

counts["Deepak"]

Advantages:

  • flexible keys
  • expected constant-time lookup
  • convenient dynamic storage

Trade-off:

Hash table → more memory
Array      → often better locality

40. Hash Table vs Binary Search Tree

Property Hash Table Balanced BST
Lookup Expected O(1) O(log n)
Insert Expected O(1) O(log n)
Delete Expected O(1) O(log n)
Ordering No Yes
Range queries Poor fit Good fit
Memory Often higher Structure-dependent
Worst-case lookup Can degrade O(log n) if balanced

The choice depends on the operation you actually need.

If you need:

"Does key exist?"

hashing is attractive.

If you need:

"Give me all keys between A and B."

an ordered structure is usually more appropriate.


41. Hash Table vs Sorting

Sometimes you can solve a problem using:

hash table

or:

sort first

For example, duplicate detection.

Hash table

Time: O(n) expected
Space: O(n)

Sort

Time: O(n log n)
Space: depends on sorting algorithm

But sorting gives you an additional property:

ordering

So the trade-off is not simply speed.

It is:

Hash table
→ fast membership

Sorting
→ ordering + structure

42. Hash Table vs Trie

For strings, a trie may be better when the problem asks about prefixes.

Example:

autocomplete("deep")

A hash table is excellent for:

exact key lookup

A trie is naturally suited for:

prefix lookup

So:

Exact match

Hash table

Prefix match

Trie

This is an important data-structure selection rule.


43. A Critical Hash-Table Pitfall

Do not assume:

dict lookup = guaranteed O(1)

The correct interview language is:

expected O(1)

Also consider:

  • collision behavior
  • load factor
  • memory overhead
  • resizing
  • key hashability
  • ordering requirements
  • adversarial inputs

At Staff level, these details matter because the interviewer is testing whether you understand the abstraction rather than merely knowing Python syntax.


44. Hash Table + Sliding Window

One of the most important combined patterns is:

Sliding window
+
Hash table

Use it when you see:

contiguous subarray
+
frequency / distinctness / coverage condition

Typical examples:

longest distinct subarray
smallest covering subarray
string permutation in a window
character frequency constraints

Mental model:

Expand right

Update hash table

Condition becomes valid

Shrink left

Update answer

45. Hash Table + Heap

Use:

Hash table + heap

when you need:

grouping
+
top K

Examples:

most frequent queries
top scores per user
top K items per category

Pattern:

key

group

frequency/state

heap

top K

46. Hash Table + Two Pointers

Use:

hash table
+
two pointers

when you need to remember prior positions.

Example:

Two Sum
def two_sum(nums, target):
    seen = {}

    for i, x in enumerate(nums):
        need = target - x

        if need in seen:
            return seen[need], i

        seen[x] = i

    return None

Complexity:

Time:  O(n) expected
Space: O(n)

The hash table converts the search for the complement from:

O(n)

into:

O(1) expected

per element.


47. Two Sum Is a Pattern, Not a Problem

The real pattern is:

Current value

What previous value would make this valid?

Look up that value

O(1) expected

This generalizes to many problems where a relationship between two elements can be expressed as:

required = function(current)

Then:

lookup(required)

becomes the core operation.


48. Hash Table Decision Tree

When should you use a hash table?

Need fast membership?
        |
       YES

      SET
Need key → value?
        |
       YES

      DICT
Need frequency?
        |
       YES

   Counter / dict
Need most recent position?
        |
       YES

      dict
Need grouping?
        |
       YES

 defaultdict(list)
Need memoization?
        |
       YES

      dict
Need exact lookup?
        |
       YES

  Hash table is attractive
Need ordered/range queries?
        |
       YES

 Hash table may not be ideal

49. Staff-Level Hash Table Questions

A Staff interviewer may ask:

Why is lookup O(1)?

Don’t simply say:

“Because dictionaries are fast.”

Explain:

key

hash function

table location

small amount of collision resolution

What happens with collisions?

Discuss:

separate chaining

or:

open addressing

What happens when the table gets full?

Discuss:

resize
+
rehash
+
amortized cost

Why can’t mutable objects generally be keys?

Because:

key identity/hash must remain stable

during membership operations.


When would you avoid a hash table?

Examples:

need ordering
need range queries
memory is highly constrained
predictable worst-case guarantees are required

50. Complexity Cheat Sheet

Problem Pattern Expected Time Space
Membership Set O(n) total O(n)
Frequency counting Dict/Counter O(n) O(k)
Palindromic permutation Frequency table O(n) O(k)
Group anagrams Hash by canonical key O(nL log L) with sorting O(nL)
Anonymous letter Frequency deficit O(n + m) O(k)
Two Sum Complement lookup O(n) expected O(n)
Nearest repeated entry Last-seen map O(n) expected O(k)
Longest distinct subarray Sliding window + map O(n) expected O(k)
Smallest covering window Frequency map + window O(n) expected O(k)
Memoization State → result Problem-dependent O(states)
LRU cache Hash table + list O(1) expected O(capacity)

51. The Most Important Patterns to Memorize

Don’t memorize dozens of solutions.

Memorize these patterns.

Pattern 1 — Membership

seen = set()

Pattern 2 — Frequency

count[x] = count.get(x, 0) + 1

Pattern 3 — Last Seen

last_seen[x] = i

Pattern 4 — Grouping

groups[key].append(value)

Pattern 5 — Complement

need = target - x

if need in seen:
    ...

Pattern 6 — Memoization

if state in cache:
    return cache[state]

Pattern 7 — Sliding Window

expand

update map

shrink

update answer

Pattern 8 — Hash + Heap

group/frequency

heap

top K

52. What Not to Do

Don’t use a hash table automatically

First understand the required operations.


Don’t ignore memory

Turning:

O(n²)

into:

O(n)

time by using:

O(n)

extra memory is a trade-off.


Don’t claim worst-case O(1)

Say:

expected O(1)

unless you have a stronger guarantee.


Don’t forget key semantics

Ask:

What exactly defines equality?

A correct hash table solution depends on the key representing the right notion of identity.


53. The Deep Staff-Level Insight

The most important idea in Part 12 is not hashing itself.

It is:

Store the information you will need later.

Suppose a brute-force algorithm repeatedly asks:

Have I seen X?
Where was X?
How many Xs have I seen?
What is associated with X?
What was the previous state of X?

A hash table can turn these repeated questions into expected constant-time lookups.

So the transformation is:

Repeated computation

Remember useful state

Index state by a key

Hash table

Avoid repeated work

This is one of the most powerful optimization patterns in algorithmic problem solving.


54. Part 11 → Part 12 Connection

Part 11 taught:

Searching

The core idea was:

Eliminate candidates efficiently.

Part 12 teaches:

Hash Tables

The core idea is:

Remember information so you don’t have to search again.

The progression is:

Part 11 — Searching

Reduce the search space

Part 12 — Hash Tables

Avoid the search entirely

Store the answer to the lookup

That is a major shift in algorithmic thinking.


55. Part 12 → Part 13 Connection

Hash tables and sorting often complement each other.

For example:

Hash table

frequency / grouping

sorting

ordered result

or:

Sort

structure becomes visible

two pointers / binary search

So the next part naturally moves to:

Part 13 — Sorting

where the focus shifts from:

fast lookup

to:

creating order that enables efficient processing

56. Final Interview Checklist

Before finishing a hash-table problem, ask:

✓ What is the key?
✓ What value/state should I store?
✓ Do I need a set or dictionary?
✓ What equality semantics do the keys have?
✓ Is expected O(1) acceptable?
✓ What is the memory cost?
✓ Can I replace repeated search with lookup?
✓ Can I combine hashing with a sliding window?
✓ Can I combine hashing with a heap?
✓ Can I use memoization?
✓ Do I need ordering instead?
✓ What happens under collisions?
✓ What happens as the table grows?
✓ Are the keys immutable/hashable?

57. Final Takeaway

Hash tables are powerful because they let us exchange:

memory

for:

speed

The core transformation is:

Search repeatedly

Remember information

Index by key

Expected O(1) lookup

Once you recognize that pattern, many interview problems become much simpler:

Duplicates

Set

Frequencies

Dictionary / Counter

Two Sum

Complement lookup

Anagrams

Canonical key

Anonymous letter

Frequency deficit

LRU cache

Hash table + linked list

Longest distinct subarray

Hash table + sliding window

Most frequent elements

Hash table + heap

Repeated computation

Memoization

Complex state hashing

Incremental hash

The Staff-level lesson is:

Don’t just ask how to search faster. Ask whether you can organize the state so that the search disappears.


Part 12 in the Series

Part 9 — Binary Trees

Part 10 — Heaps

Part 11 — Searching

Part 12 — Hash Tables

Part 13 — Sorting

The progression is intentional:

Heaps

Prioritize candidates

Searching

Eliminate candidates

Hash Tables

Remember candidates

Sorting

Create structure among candidates

That is the foundation for solving unfamiliar coding-interview problems rather than memorizing individual solutions.