Discord

Checklist Bulgaria selection 2025 IOAI Team Selection · Task 6

Dual-Task Tile Recognition Challenge

For each tile of a shuffled mix of two STL-10 images, predict which image it came from and its original 3x3 position.

  • Vision
  • Multi-task image classification

The task

Two STL-10 images of different classes are resized to 96x96, each split into a 3x3 grid of 32x32 tiles, and the 18 tiles are shuffled into a 3x6 grid. For every tile the model must predict the source image (0 = first, brighter image; 1 = second, darker image) and the tile's original position (0-8).

The model class must be named TileClassifier, have at least 3 Conv2d layers with batch normalisation and pooling and at least 2 Linear layers per head, accept tensors [Bx18, 3, 32, 32] and output source logits [Bx18, 2] and position logits [Bx18, 9]; loss, optimiser and learning rate are free.

Abridged by SOTA from the official materials. The official statement has the exact rules, and it wins wherever this summary differs.

In English

Some of this task's files were published only in Bulgarian. SOTA translated that file into English on 17 September 2026. Only the words changed in the notebooks: markdown, code comments, messages and printed output. The code, file names and paths are the original's, so a translated notebook runs with the original data.

Read the contestant's solution by bozhidara puhaleva (with the statement) in English 462 words and 11 code cells

Dual Tile Recognition Task

Introduction: This task involves the analysis of mixed images, created from pairs of images from the STL-10 dataset, with the aim of predicting both the source image and the original position of each individual tile.

I. Overview of the problem

The task uses images created by combining two different images from STL-10 with different class labels. Each original image is resized to 96x96 pixels and split into a 3x3 grid of tiles (each of size 32x32 pixels). All 18 tiles from the two images are then shuffled at random and arranged into a 3x6 grid, creating mixed images for analysis.

II. Dataset

The dataset consists of 5000 mixed images for training and 500 mixed images for validation. Each mixed image contains 18 tiles from two different source images from STL-10. The data are split, with the images and labels prepared for immediate use. The file sizes are optimised for fast training on moderately powerful hardware or on free graphics processing units (GPUs) in Colab.

III. Task

Design and implement a neural network model in PyTorch that analyses the mixed images and predicts two properties for every tile. The specific requirements are as follows:

  1. Source classification: Determine which original image the tile comes from:
  • Label 0: Tile from the first source image (the brighter image).
  • Label 1: Tile from the second source image (the darker image).
  1. Position classification: Identify the original position of the tile in the 3x3 grid:
  • Labels 0–8: Corresponding to the positions in the original 3x3 grid.

Model requirements:

  • Name the model class TileClassifier(). Using other names may lead to problems during scoring.
  • Include at least 3 convolutional layers (nn.Conv2d) with the corresponding batch normalization and pooling layers.
  • Include at least 2 fully connected layers (nn.Linear) for each of the two classification heads.
    • Use suitable activation functions, for example nn.ReLU.
  • The model must accept input tensors with dimensions [B×18, 3, 32, 32], where B is the batch size.
  • The model must return two sets of logits: source_logits with dimensions [B×18, 2] and position_logits with dimensions [B×18, 9].
  • The loss function, the optimiser and the learning rate may be chosen freely.

IV. Scoring

Performance is measured with accuracy metrics for the two classification tasks. Scoring is carried out on a test set of 5000 shuffled images:

  • Source Accuracy:: correct_source_predictions / total_tiles
  • Position Accuracy: correct_position_predictions / total_tiles

V. Submission requirements

For the final submission, archive the following 2 files:

  • A file with the predictions in JSON format (predictions.json). The file must contain the predictions for all tiles of the test set, in sequential order. File format:
[
    {"source": 0, "position": 5},
    {"source": 1, "position": 2},
    ...
]

- This notebook, renamed appropriately.

!pip install gdown
!gdown 1_cm2IzYrohgqnXEWBEXO1UtYBW2UqFdZ
! unzip mixed_dataset_competition.zip
########## BASELINE
# ====================================================================================================
# CONTESTANTS' ZONE: Change the code below this line, keeping the interfaces of the methods and classes
# ====================================================================================================
# Some ideas:
# 1. Add global-context features that take into account the relations between all tiles, instead of processing each one independently.
# 2. Experiment with different pooling strategies and methods for aggregating features in the network.
# 3. Try multi-scale feature extraction approaches in order to capture information at different levels of detail.
# 4. Change the way in which features are shared or split between the two classification tasks within the model architecture.
# 5. Adjust the depth, width and connection schemes of the network in order to better capture the hierarchical nature of visual features.


class TileClassifier(nn.Module):
    def __init__(self, kernel_size=3, num_layers=3):
        super(TileClassifier, self).__init__()

        # Defining the basic CNN layers
        cnn_layers = []
        in_channels = 3

        # Configuring the channels for each layer
        channels = [32, 64, 128, 256][:num_layers]

        for i, out_channels in enumerate(channels):
            cnn_layers.append(
                nn.Conv2d(
                    in_channels,
                    out_channels,
                    kernel_size=kernel_size,
                    padding=kernel_size // 2,
                )
            )
            cnn_layers.append(nn.BatchNorm2d(out_channels))
            cnn_layers.append(nn.ReLU(inplace=True))
            cnn_layers.append(nn.MaxPool2d(2))
            in_channels = out_channels

        self.cnn = nn.Sequential(*cnn_layers)

        # Compute the size of the output features from the input and the pooling layers
        feature_size = 32 // (2**num_layers)
        if feature_size < 1:
            feature_size = 1  # Minimum feature size

        flat_features = channels[-1] * feature_size * feature_size

        # Position classifier (9 positions) - not changed
        self.position_classifier = nn.Sequential(
            nn.Linear(flat_features, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 9)
        )
        self.source_classifier = nn.Sequential(
            nn.Linear(flat_features, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 2)
        )

    def forward(self, x):
        # x shape: [B * 18, 3, 32, 32], where B is batch_size from DataLoader
        num_tiles_per_image = 18  # Fixed for this problem

        tile_cnn_features = self.cnn(x)
        flat_tile_features = torch.flatten(tile_cnn_features, 1)
        position_logits = self.position_classifier(flat_tile_features)
        source_logits = self.source_classifier(flat_tile_features)
        return source_logits, position_logits

def setup_training_components(device):
    lr = 0.001
    kernel_size = 3
    num_layers = 2

    model = TileClassifier(kernel_size=kernel_size, num_layers=num_layers).to(device)
    print("Model architecture:")
    print(model)

    criterion_source = nn.CrossEntropyLoss()
    criterion_position = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=lr)
    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=5)

    return model, criterion_source, criterion_position, optimizer, scheduler

# ==================================================================================
# DO NOT CHANGE THE CODE BELOW THIS LINE!
# ==================================================================================
########## BASELINE
# ====================================================================================================
# CONTESTANTS' ZONE: Change the code below this line, keeping the interfaces of the methods and classes
# ====================================================================================================
# Some ideas:
# 1. Add global-context features that take into account the relations between all tiles, instead of processing each one independently.
# 2. Experiment with different pooling strategies and methods for aggregating features in the network.
# 3. Try multi-scale feature extraction approaches in order to capture information at different levels of detail.
# 4. Change the way in which features are shared or split between the two classification tasks within the model architecture.
# 5. Adjust the depth, width and connection schemes of the network in order to better capture the hierarchical nature of visual features.

########## BEST
import torch.nn.functional as F
import torch.nn as nn
import torch

class TileClassifier(nn.Module):
    def __init__(self):
        super(TileClassifier, self).__init__()
        self.cnn = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2),  # 32->16
            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2),  # 16->8
            nn.Conv2d(128, 256, kernel_size=3, padding=1),
            nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2),  # 8->4
            nn.Conv2d(256, 512, kernel_size=3, padding=1),
            nn.BatchNorm2d(512), nn.ReLU(), nn.AdaptiveAvgPool2d(1),  # 4->1x1, feature vector per tile
        )
        self.feature_dim = 512  # 512*1*1 = 512

        # Attention layer for global context
        self.attention = nn.MultiheadAttention(embed_dim=self.feature_dim, num_heads=8)

        # Classification heads
        self.position_classifier = nn.Sequential(
            nn.Linear(self.feature_dim * 2, 256), nn.ReLU(),
            nn.Dropout(0.3), nn.Linear(256, 9)
        )
        self.source_classifier = nn.Sequential(
            nn.Linear(self.feature_dim * 2, 256), nn.ReLU(),
            nn.Dropout(0.3), nn.Linear(256, 2)
        )

    def forward(self, x):
        # x: (B*18, 3, 32, 32)
        B_tiles = x.size(0)
        features = self.cnn(x).view(B_tiles, -1)  # (B*18, feature_dim)

        B = B_tiles // 18
        features = features.view(B, 18, self.feature_dim)  # (B, 18, D)

        # Prepare for attention: (seq_len, batch, embed_dim)
        features_t = features.permute(1, 0, 2)  # (18, B, D)

        # Self-attention over tiles to get context-aware features
        attn_output, _ = self.attention(features_t, features_t, features_t)  # (18, B, D)
        attn_output = attn_output.permute(1, 0, 2)  # (B, 18, D)

        # Combine local + global context from attention
        global_context = attn_output.mean(dim=1, keepdim=True).repeat(1, 18, 1)  # (B,18,D)
        features_with_context = torch.cat([features, global_context], dim=2)  # (B,18,2D)

        # Flatten back
        features_with_context = features_with_context.view(B*18, -1)  # (B*18, 2D)

        position_logits = self.position_classifier(features_with_context)
        source_logits = self.source_classifier(features_with_context)

        return source_logits, position_logits
def setup_training_components(device):
    lr = 0.001
    kernel_size = 3
    num_layers = 2

    #model = TileClassifier(kernel_size=kernel_size, num_layers=num_layers).to(device)
    model = TileClassifier().to(device)
    print("Model architecture:")
    print(model)

    criterion_source = nn.CrossEntropyLoss()
    criterion_position = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=5)

    return model, criterion_source, criterion_position, optimizer, scheduler

# ==================================================================================
# DO NOT CHANGE THE CODE BELOW THIS LINE!
# ==================================================================================
import torch.nn.functional as F
import torch.nn as nn
import torch
import torch.optim as optim

class TileClassifier(nn.Module):
    def __init__(self):
        super(TileClassifier, self).__init__()
        self.cnn = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2),  # 32->16
            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2),  # 16->8
            nn.Conv2d(128, 256, kernel_size=3, padding=1),
            nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2),  # 8->4
            nn.Conv2d(256, 512, kernel_size=3, padding=1),
            nn.BatchNorm2d(512), nn.ReLU(), nn.AdaptiveAvgPool2d(1),  # 4->1x1, feature vector per tile
        )
        self.feature_dim = 512  # 512*1*1 = 512

        # Attention layer for global context
        self.attention = nn.MultiheadAttention(embed_dim=self.feature_dim, num_heads=8)

        # LayerNorm layers for Transformer-style residual connections
        self.ln1 = nn.LayerNorm(self.feature_dim)
        self.ln2 = nn.LayerNorm(self.feature_dim)

        # Feed-forward block (like Transformer FFN)
        self.ff = nn.Sequential(
            nn.Linear(self.feature_dim, self.feature_dim),
            nn.ReLU(),
            nn.Linear(self.feature_dim, self.feature_dim)
        )

        # Classification heads
        self.position_classifier = nn.Sequential(
            nn.Linear(self.feature_dim * 2, 256), nn.ReLU(),
            nn.Dropout(0.3), nn.Linear(256, 9)
        )
        self.source_classifier = nn.Sequential(
            nn.Linear(self.feature_dim * 2, 256), nn.ReLU(),
            nn.Dropout(0.3), nn.Linear(256, 2)
        )

    def forward(self, x):
        # x: (B*18, 3, 32, 32)
        B_tiles = x.size(0)
        features = self.cnn(x).view(B_tiles, -1)  # (B*18, feature_dim)

        B = B_tiles // 18
        features = features.view(B, 18, self.feature_dim)  # (B, 18, D)

        # Prepare for attention: (seq_len, batch, embed_dim)
        features_t = features.permute(1, 0, 2)  # (18, B, D)

        # Multihead Attention with residual and LayerNorm
        attn_output, _ = self.attention(features_t, features_t, features_t)  # (18, B, D)
        x = self.ln1(features_t + attn_output)  # Residual + LayerNorm

        ff_out = self.ff(x)  # Feed-forward network
        x = self.ln2(x + ff_out)  # Residual + LayerNorm

        attn_output = x.permute(1, 0, 2)  # (B, 18, D)

        # Combine local + global context from attention
        global_context = attn_output.mean(dim=1, keepdim=True).repeat(1, 18, 1)  # (B,18,D)
        features_with_context = torch.cat([features, global_context], dim=2)  # (B,18,2D)

        # Flatten back
        features_with_context = features_with_context.view(B*18, -1)  # (B*18, 2D)

        position_logits = self.position_classifier(features_with_context)
        source_logits = self.source_classifier(features_with_context)

        return source_logits, position_logits


def setup_training_components(device):
    lr = 0.001
    #model = TileClassifier(kernel_size=kernel_size, num_layers=num_layers).to(device)
    model = TileClassifier().to(device)
    print("Model architecture:")
    print(model)

    criterion_source = nn.CrossEntropyLoss()
    criterion_position = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=5)

    return model, criterion_source, criterion_position, optimizer, scheduler
def get_device():
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f"Using device: {device}")
    return device

def get_data_loaders(args):
    train_dataset = MixedSTL10Dataset(root_dir=args.data_dir, split="train")
    val_dataset = MixedSTL10Dataset(root_dir=args.data_dir, split="val")

    print(f"Training set size: {len(train_dataset)}")
    print(f"Validation set size: {len(val_dataset)}")

    train_loader = DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, num_workers=4)
    val_loader = DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, num_workers=4)
    return train_loader, val_loader


def train_one_epoch(model, loader, crit_source, crit_pos, optimizer, device, sw, pw):
    model.train()
    total_loss, src_correct, pos_correct, total = 0.0, 0, 0, 0

    for tiles, src_labels, pos_labels in loader:
        batch_size = tiles.size(0)
        tiles = tiles.view(-1, 3, 32, 32).to(device)
        src_labels = src_labels.view(-1).to(device)
        pos_labels = pos_labels.view(-1).to(device)

        src_logits, pos_logits = model(tiles)
        src_loss = crit_source(src_logits, src_labels)
        pos_loss = crit_pos(pos_logits, pos_labels)
        loss = sw * src_loss + pw * pos_loss

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        total_loss += loss.item() * batch_size
        src_correct += (src_logits.argmax(1) == src_labels).sum().item()
        pos_correct += (pos_logits.argmax(1) == pos_labels).sum().item()
        total += src_labels.size(0)

    return {
        "loss": total_loss / len(loader.dataset),
        "source_acc": src_correct / total,
        "position_acc": pos_correct / total,
    }

def evaluate(model, loader, crit_source, crit_pos, device, sw, pw):
    model.eval()
    total_loss, src_correct, pos_correct, total = 0.0, 0, 0, 0

    with torch.no_grad():
        for tiles, src_labels, pos_labels in loader:
            batch_size = tiles.size(0)
            tiles = tiles.view(-1, 3, 32, 32).to(device)
            src_labels = src_labels.view(-1).to(device)
            pos_labels = pos_labels.view(-1).to(device)

            src_logits, pos_logits = model(tiles)
            src_loss = crit_source(src_logits, src_labels)
            pos_loss = crit_pos(pos_logits, pos_labels)
            loss = sw * src_loss + pw * pos_loss

            total_loss += loss.item() * batch_size
            src_correct += (src_logits.argmax(1) == src_labels).sum().item()
            pos_correct += (pos_logits.argmax(1) == pos_labels).sum().item()
            total += src_labels.size(0)

    return {
        "loss": total_loss / len(loader.dataset),
        "source_acc": src_correct / total,
        "position_acc": pos_correct / total,
    }


def print_epoch_summary(epoch, total_epochs, start_time, train_metrics, val_metrics):
    print(f"Epoch {epoch + 1}/{total_epochs} | Time: {time.time() - start_time:.2f}s")
    print(
        f"Train Loss: {train_metrics['loss']:.4f} | Source Acc: {train_metrics['source_acc']:.4f} | "
        f"Position Acc: {train_metrics['position_acc']:.4f}"
    )
    print(
        f"Val Loss: {val_metrics['loss']:.4f} | Source Acc: {val_metrics['source_acc']:.4f} | "
        f"Position Acc: {val_metrics['position_acc']:.4f}"
    )

def save_model_checkpoint(model, optimizer, epoch, metrics, path):
    torch.save({
        "epoch": epoch,
        "model_state_dict": model.state_dict(),
        "optimizer_state_dict": optimizer.state_dict(),
        "val_loss": metrics["loss"],
        "val_source_acc": metrics["source_acc"],
        "val_position_acc": metrics["position_acc"],
    }, path)
    print(f"Saved new best model with validation loss: {metrics['loss']:.4f}")


def train_model(args):
    device = get_device()
    train_loader, val_loader = get_data_loaders(args)
    model, criterion_source, criterion_position, optimizer, scheduler = setup_training_components(device)

    best_val_loss = float("inf")
    os.makedirs(args.output_dir, exist_ok=True)
    best_model_path = os.path.join(args.output_dir, "best_model.pth")

    source_weight = 2.0
    position_weight = 1.0

    print("Starting training...")
    for epoch in range(args.epochs):
        start_time = time.time()

        train_metrics = train_one_epoch(
            model, train_loader, criterion_source, criterion_position, optimizer, device, source_weight, position_weight
        )

        val_metrics = evaluate(
            model, val_loader, criterion_source, criterion_position, device, source_weight, position_weight
        )

        scheduler.step(val_metrics['loss'])

        print_epoch_summary(epoch, args.epochs, start_time, train_metrics, val_metrics)

        if val_metrics["loss"] < best_val_loss:
            best_val_loss = val_metrics["loss"]
            save_model_checkpoint(model, optimizer, epoch, val_metrics, best_model_path)

    print(f"Training complete. Best validation loss: {best_val_loss:.4f}")
    return model

def create_submission_file(model, test_loader, device, output_path="predictions.json"):
    model.eval()
    all_predictions = []

    print("Generating predictions for the test set...")
    with torch.no_grad():
        for tiles, _ in test_loader:
            tiles = tiles.view(-1, 3, 32, 32).to(device)

            src_logits, pos_logits = model(tiles)

            src_preds = torch.argmax(src_logits, dim=1).cpu().numpy()
            pos_preds = torch.argmax(pos_logits, dim=1).cpu().numpy()

            for i in range(src_preds.shape[0]):
                all_predictions.append({
                    "source": int(src_preds[i]),
                    "position": int(pos_preds[i])
                })

    with open(output_path, 'w') as f:
        json.dump(all_predictions, f, indent=4)

    print(f"Predictions saved to {output_path}")


def visualize_mixed_and_reordered(dataset, idx):
    tiles, source_labels, position_labels = dataset[idx] # tiles shape: [18, 3, 32, 32]

    tiles_np = tiles.permute(0, 2, 3, 1).numpy() # Shape: [18, 32, 32, 3]

    scrambled_image = np.zeros((3 * 32, 6 * 32, 3), dtype=np.float32)
    for i in range(18):
        row = i // 6
        col = i % 6
        scrambled_image[row*32:(row+1)*32, col*32:(col+1)*32, :] = tiles_np[i]

    source_0_tiles = []
    source_1_tiles = []
    source_0_positions = []
    source_1_positions = []

    for i in range(18):
        if source_labels[i] == 0:
            source_0_tiles.append(tiles_np[i])
            source_0_positions.append(position_labels[i])
        else:
            source_1_tiles.append(tiles_np[i])
            source_1_positions.append(position_labels[i])

    source_0_sorted_indices = np.argsort(source_0_positions)
    source_1_sorted_indices = np.argsort(source_1_positions)

    source_0_sorted_tiles = [source_0_tiles[i] for i in source_0_sorted_indices]
    source_1_sorted_tiles = [source_1_tiles[i] for i in source_1_sorted_indices]


    reordered_image_source0 = np.zeros((3 * 32, 3 * 32, 3), dtype=np.float32)
    reordered_image_source1 = np.zeros((3 * 32, 3 * 32, 3), dtype=np.float32)

    for i in range(9):
        row = i // 3
        col = i % 3
        reordered_image_source0[row*32:(row+1)*32, col*32:(col+1)*32, :] = source_0_sorted_tiles[i]
        reordered_image_source1[row*32:(row+1)*32, col*32:(col+1)*32, :] = source_1_sorted_tiles[i]

    fig, axes = plt.subplots(1, 3, figsize=(18, 6))

    axes[0].imshow(scrambled_image)
    axes[0].set_title("Mixed image")
    axes[0].axis("off")

    axes[1].imshow(reordered_image_source0)
    axes[1].set_title("Reordered image (Source 0)")
    axes[1].axis("off")

    axes[2].imshow(reordered_image_source1)
    axes[2].set_title("Reordered image (Source 1)")
    axes[2].axis("off")

    plt.tight_layout()
    plt.show()
ls
data_dir = "./mixed_dataset"
output_dir = "./model_output"
batch_size = 8
epochs = 10
lr = 0.001

# Creating an args object with these parameters
class Args:
    def __init__(self):
        self.data_dir = data_dir
        self.output_dir = output_dir
        self.batch_size = batch_size
        self.epochs = epochs
        self.lr = lr

args = Args()
train_dataset = MixedSTL10Dataset(root_dir=args.data_dir, split="train")
visualize_mixed_and_reordered(train_dataset, 3)
#BEST
train_model(args)
# Test set and loading
test_dataset = MixedSTL10Dataset(root_dir=args.data_dir, split="test") # Use val as test data for this example
test_loader = DataLoader(test_dataset, batch_size=args.batch_size, shuffle=False, num_workers=4)

device = get_device()

# Load the best model
best_model_path = os.path.join(args.output_dir, "best_model.pth")
checkpoint = torch.load(best_model_path)
model, _, _, _, _ = setup_training_components(device) # Re-initialize model architecture
model.load_state_dict(checkpoint['model_state_dict'])

# Create a file with the final predictions on the test set
create_submission_file(model, test_loader, device)

Translated by SOTA. The Bulgarian original is the official version and wins wherever the two differ. The official task notebook (IOAI_Team_Selection_Bulgaria_Task_6.ipynb) is already in English; this is a contestant's solution whose Bulgarian copy of the statement gives other dataset sizes and needs the imports and MixedSTL10Dataset class from the task notebook. The titles inside the saved figure are still in Bulgarian. If you organise this olympiad and would like the translation removed, email [email protected] and we will take it down.

At a glance

You get
mixed_dataset/ with train, val and test images and .npy labels for train and val.
You submit
Model predictions for the tiles (a sample solution writes predictions.json).
Scoring
Source accuracy and position accuracy (correct / total tiles); combined score = (source accuracy + position accuracy) / 2.
Rules
  • Model class TileClassifier with the architectural minimums above.
Format
Bulgarian IOAI 2025 team selection, Day 2, Task 6. Dates and format are not published in the repository.

Details

Year
2025
Round
IOAI Team Selection · Task 6
Language
English; English translation by SOTA
License
Not stated by the source