diff --git a/exercises/practice/isogram/.approaches/bitfield/content.md b/exercises/practice/isogram/.approaches/bitfield/content.md new file mode 100644 index 00000000..7c701360 --- /dev/null +++ b/exercises/practice/isogram/.approaches/bitfield/content.md @@ -0,0 +1,29 @@ +# Bit field + +```zig +const std = @import("std"); + +pub fn isIsogram(str: []const u8) bool { + var seen: u32 = 0; + for (str) |c| { + if (!std.ascii.isAlphabetic(c)) continue; + const bit = @as(u32, 1) << @intCast((c | 0x20) - 'a'); + if (seen & bit != 0) return false; + seen |= bit; + } + return true; +} +``` + +Since there are only 26 letters, the set of seen letters fits in the bits of a single `u32`: bit `0` stands for `a`/`A` and bit `25` for `z`/`Z`. + +Non-letters are skipped with [`std.ascii.isAlphabetic`][is-alphabetic]. +For a letter, `c | 0x20` sets the bit that distinguishes lowercase from uppercase in ASCII (`'A'` is `0x41`, `'a'` is `0x61`), converting the letter to lowercase; subtracting `'a'` then yields the letter's index. +The `@intCast` narrows that index to the `u5` shift amount that shifting a `u32` requires. + +If the letter's bit is already set in `seen`, a letter has repeated and the function returns `false` immediately, so the input is only scanned as far as the first repeated letter. + +Compared with the [bool array approach][approach-bool-array], the state is a single register-sized integer, and set membership and insertion are single bitwise operations. + +[is-alphabetic]: https://ziglang.org/documentation/master/std/#std.ascii.isAlphabetic +[approach-bool-array]: https://exercism.org/tracks/zig/exercises/isogram/approaches/bool-array diff --git a/exercises/practice/isogram/.approaches/bitfield/snippet.txt b/exercises/practice/isogram/.approaches/bitfield/snippet.txt new file mode 100644 index 00000000..e9e40631 --- /dev/null +++ b/exercises/practice/isogram/.approaches/bitfield/snippet.txt @@ -0,0 +1,8 @@ +var seen: u32 = 0; +for (str) |c| { + if (!std.ascii.isAlphabetic(c)) continue; + const bit = @as(u32, 1) << @intCast((c | 0x20) - 'a'); + if (seen & bit != 0) return false; + seen |= bit; +} +return true; diff --git a/exercises/practice/isogram/.approaches/bool-array/content.md b/exercises/practice/isogram/.approaches/bool-array/content.md new file mode 100644 index 00000000..31be6b52 --- /dev/null +++ b/exercises/practice/isogram/.approaches/bool-array/content.md @@ -0,0 +1,28 @@ +# Bool array + +```zig +pub fn isIsogram(s: []const u8) bool { + var letters: [26]bool = @splat(false); + for (s) |c| { + const i = switch (c) { + 'A'...'Z' => c - 'A', + 'a'...'z' => c - 'a', + else => continue, + }; + if (letters[i]) return false; + letters[i] = true; + } + return true; +} +``` + +A `[26]bool` array records which letters have been seen so far, with element `0` standing for `a`/`A` and element `25` for `z`/`Z`. + +The `switch` maps both uppercase and lowercase letters to that shared index, so case is ignored. +Its `else` prong uses `continue` as the prong body, skipping hyphens, spaces, and any other non-letter directly from within the switch — no separate `if` is needed. + +For each letter, if its flag is already set, a letter has repeated and the function returns `false` immediately. +Otherwise the flag is set and scanning continues. +The early return means the input is only scanned as far as the first repeated letter. + +The array lives on the stack and its size is known at compile time, so this approach performs no allocation. diff --git a/exercises/practice/isogram/.approaches/bool-array/snippet.txt b/exercises/practice/isogram/.approaches/bool-array/snippet.txt new file mode 100644 index 00000000..51ea81a8 --- /dev/null +++ b/exercises/practice/isogram/.approaches/bool-array/snippet.txt @@ -0,0 +1,8 @@ +var letters: [26]bool = @splat(false); +for (s) |c| { + const i = switch (c) { + 'A'...'Z' => c - 'A', + 'a'...'z' => c - 'a', + else => continue, + }; + if (letters[i]) return false; diff --git a/exercises/practice/isogram/.approaches/config.json b/exercises/practice/isogram/.approaches/config.json new file mode 100644 index 00000000..04e4af10 --- /dev/null +++ b/exercises/practice/isogram/.approaches/config.json @@ -0,0 +1,39 @@ +{ + "introduction": { + "authors": [ + "keiravillekode" + ] + }, + "approaches": [ + { + "uuid": "41d585a0-fc4e-4108-8613-1d21a9477e79", + "slug": "bool-array", + "title": "Bool array", + "blurb": "Track which letters have been seen with an array of 26 booleans.", + "authors": [ + "massivelivefun" + ], + "contributors": [ + "keiravillekode" + ] + }, + { + "uuid": "69518e6c-d384-434e-afcd-49afa6085488", + "slug": "bitfield", + "title": "Bit field", + "blurb": "Track which letters have been seen with the bits of a 32-bit integer.", + "authors": [ + "keiravillekode" + ] + }, + { + "uuid": "ca88bd01-a271-4c42-97c7-d6896b88f889", + "slug": "simd", + "title": "SIMD", + "blurb": "Process blocks of input characters at once with vector operations.", + "authors": [ + "keiravillekode" + ] + } + ] +} diff --git a/exercises/practice/isogram/.approaches/introduction.md b/exercises/practice/isogram/.approaches/introduction.md new file mode 100644 index 00000000..ed55fb8c --- /dev/null +++ b/exercises/practice/isogram/.approaches/introduction.md @@ -0,0 +1,77 @@ +# Introduction + +An isogram is a word with no repeated letters, so every approach comes down to detecting whether any letter occurs twice, ignoring case and ignoring characters that are not letters. +The approaches differ in how they remember which letters have already been seen. + +## General guidance + +Since there are only 26 letters, the state that needs to be tracked is small and of fixed size. +No allocation is needed: an array, an integer, or a vector on the stack is enough. +Case can be ignored by mapping both `'A'...'Z'` and `'a'...'z'` to an index in the range `0...25`. + +## Approach: bool array + +```zig +pub fn isIsogram(s: []const u8) bool { + var letters: [26]bool = @splat(false); + for (s) |c| { + const i = switch (c) { + 'A'...'Z' => c - 'A', + 'a'...'z' => c - 'a', + else => continue, + }; + if (letters[i]) return false; + letters[i] = true; + } + return true; +} +``` + +Each letter maps to an element of a `[26]bool` array, which records whether the letter has been seen before. +For details, see the [bool array approach][approach-bool-array]. + +## Approach: bit field + +```zig +const std = @import("std"); + +pub fn isIsogram(str: []const u8) bool { + var seen: u32 = 0; + for (str) |c| { + if (!std.ascii.isAlphabetic(c)) continue; + const bit = @as(u32, 1) << @intCast((c | 0x20) - 'a'); + if (seen & bit != 0) return false; + seen |= bit; + } + return true; +} +``` + +Each letter maps to one bit of a `u32`, so the whole set of seen letters fits in a single integer. +For details, see the [bit field approach][approach-bitfield]. + +## Approach: SIMD + +```zig +pub fn isIsogram(str: []const u8) bool { + var seen: u32 = 0; + var i: usize = 0; + while (i + block_len <= str.len) : (i += block_len) { + const mask = letterMask(str[i..][0..block_len].*) orelse return false; + if (seen & mask != 0) return false; + seen |= mask; + } + // Pad the remaining bytes with zeroes to fill one last block. + var padded = [_]u8{0} ** block_len; + @memcpy(padded[0 .. str.len - i], str[i..]); + const mask = letterMask(padded) orelse return false; + return seen & mask == 0; +} +``` + +The input is processed a block of characters at a time: each block is loaded into a `@Vector`, and vector operations lowercase all its characters, find the letters, and turn them into a bit mask in one pass. +For details, see the [SIMD approach][approach-simd]. + +[approach-bool-array]: https://exercism.org/tracks/zig/exercises/isogram/approaches/bool-array +[approach-bitfield]: https://exercism.org/tracks/zig/exercises/isogram/approaches/bitfield +[approach-simd]: https://exercism.org/tracks/zig/exercises/isogram/approaches/simd diff --git a/exercises/practice/isogram/.approaches/simd/content.md b/exercises/practice/isogram/.approaches/simd/content.md new file mode 100644 index 00000000..f07dea5e --- /dev/null +++ b/exercises/practice/isogram/.approaches/simd/content.md @@ -0,0 +1,67 @@ +# SIMD + +```zig +const std = @import("std"); + +const block_len = std.simd.suggestVectorLength(u8) orelse 8; +const Block = @Vector(block_len, u8); + +/// Returns a bit mask of the letters in `block`, or null if `block` +/// contains a repeated letter. +fn letterMask(block: Block) ?u32 { + const lowered = block | @as(Block, @splat(0x20)); + // Every non-letter lane is clamped into an extra bucket, index 26. + const indices = @min(lowered -% @as(Block, @splat('a')), @as(Block, @splat(26))); + const bits = @as(@Vector(block_len, u32), @splat(1)) << @intCast(indices); + const mask = @reduce(.Or, bits) & ((1 << 26) - 1); + const letter_count = std.simd.countTrues(indices != @as(Block, @splat(26))); + if (@popCount(mask) != letter_count) return null; + return mask; +} + +pub fn isIsogram(str: []const u8) bool { + var seen: u32 = 0; + var i: usize = 0; + while (i + block_len <= str.len) : (i += block_len) { + const mask = letterMask(str[i..][0..block_len].*) orelse return false; + if (seen & mask != 0) return false; + seen |= mask; + } + // Pad the remaining bytes with zeroes to fill one last block. + var padded = [_]u8{0} ** block_len; + @memcpy(padded[0 .. str.len - i], str[i..]); + const mask = letterMask(padded) orelse return false; + return seen & mask == 0; +} +``` + +Zig's [`@Vector`][vectors] type provides portable SIMD: an operation on vectors is applied to all elements at once, and compiles to the target's vector instructions where available. +Instead of examining one character per loop iteration, this approach loads a whole block of input characters into a vector and processes them together, following the pattern described in [Everyone Should Know SIMD][mitchellh-simd]. + +[`std.simd.suggestVectorLength`][suggest-vector-length] picks the block length the target's vector registers can hold — for example 32 on x86-64 with AVX2 — and returns `null` for targets without SIMD support, so a fallback is provided. + +Like the [bit field approach][approach-bitfield], the set of letters seen so far is kept as bits of a `u32`. +The work of turning a block of characters into such a bit mask is done entirely with vector operations in `letterMask`: + +- `block | @splat(0x20)` sets the bit that distinguishes lowercase from uppercase in ASCII, converting every uppercase letter in the block to lowercase in one operation. +- Subtracting `'a'` gives each letter's index in the range `0...25`. + The subtraction is wrapping (`-%`), so a non-letter lane ends up with some value of at least 26, and `@min` clamps it to exactly 26 — an extra bucket where all the non-letters land. +- Each lane's index becomes a single set bit with a vector shift, and `@reduce(.Or, bits)` collapses the lanes into a single integer holding the set of letters in the block. + Masking with `(1 << 26) - 1` strips bit 26, throwing away the non-letter bucket. + +A repeated letter *within* the block would produce the same bit in two lanes, and the duplicate would vanish in the `Or` reduction. +The `@popCount` check catches this: if the number of distinct bits in the mask differs from the number of letter lanes ([`std.simd.countTrues`][count-trues] of the lanes whose index is not 26), some letter occurred twice. +Repeats *across* blocks are caught by intersecting each block's mask with the accumulated `seen` set, exactly as in the bit field approach. + +The final partial block is copied into a zero-padded buffer and processed the same way — padding is safe because `0 | 0x20` is `0x20` (a space), which is not a letter. +This keeps a single code path instead of a separate scalar loop for the tail. + +For this exercise's short words, the setup cost of SIMD outweighs its benefit, and the [bool array][approach-bool-array] or bit field approaches are simpler. +The technique shines on long inputs, where each iteration consumes `block_len` characters instead of one. + +[vectors]: https://ziglang.org/documentation/master/#Vectors +[suggest-vector-length]: https://ziglang.org/documentation/master/std/#std.simd.suggestVectorLength +[count-trues]: https://ziglang.org/documentation/master/std/#std.simd.countTrues +[mitchellh-simd]: https://mitchellh.com/writing/everyone-should-know-simd +[approach-bool-array]: https://exercism.org/tracks/zig/exercises/isogram/approaches/bool-array +[approach-bitfield]: https://exercism.org/tracks/zig/exercises/isogram/approaches/bitfield diff --git a/exercises/practice/isogram/.approaches/simd/snippet.txt b/exercises/practice/isogram/.approaches/simd/snippet.txt new file mode 100644 index 00000000..f82d55ba --- /dev/null +++ b/exercises/practice/isogram/.approaches/simd/snippet.txt @@ -0,0 +1,7 @@ +var seen: u32 = 0; +var i: usize = 0; +while (i + block_len <= str.len) : (i += block_len) { + const mask = letterMask(str[i..][0..block_len].*) orelse return false; + if (seen & mask != 0) return false; + seen |= mask; +} diff --git a/exercises/practice/isogram/.meta/example.zig b/exercises/practice/isogram/.meta/example.zig index 1b2d6884..97e38949 100644 --- a/exercises/practice/isogram/.meta/example.zig +++ b/exercises/practice/isogram/.meta/example.zig @@ -1,5 +1,5 @@ pub fn isIsogram(s: []const u8) bool { - var letters = [_]bool{false} ** 26; + var letters: [26]bool = @splat(false); for (s) |c| { const i = switch (c) { 'A'...'Z' => c - 'A',