If you are going to offer easy global access to all the tiles, you might as well make that part of the Tile class. Then it’ll be easy to find, the API will be nicely named (e.g. Tile.Get), and you can fully encapsulate all the implementation details of registering and deregistering instances inside the one class.
You’d then also want to give Tile an early execution order, so that its instances will be able to insert themselves into the array before other classes try to retrieve them.
[DefaultExecutionOrder(-10000)]
public sealed class Tile : MonoBehaviour
{
static readonly Tile[] instances = new Tile[TileCount];
public static Tile Get(int x, int y) => instances[GetTileIndex(x, y)]
void OnEnable() => instances[GetTileIndex(x, y)] = this;
void OnDisable() => instances[GetTileIndex(x, y)] = null;
...
}
If you want to use a ScriptableObject instead, you can add Register and Deregister methods to it, give all your tiles a reference to it via a serialized field, and have them register themselves at runtime during OnEnable, and deregister during OnDisable.
[CreateAssetMenu]
public class TileCollection : ScriptableObject
{
readonly Tile[] instances = new Tile[TileCount];
public Tile Get(int x, int y) => instances[GetTileIndex(x, y)]
internal void Register(Tile tile) => instances[GetTileIndex(tile.X, tile.Y)] = tile;
internal void Deregister(int x, int y) => instances[GetTileIndex(x, y)];
...
}
Edit: Third option, with no need to rely on script execution order:
public class Tiles : MonoBehaviour
{
static Tiles instance;
[SerializeField] Tile[] tiles;
public static Tile Get(int x, int y) => (instance ??= Object.FindAnyObjectByType<Tile>()).tiles[GetTileIndex(x, y)];
void OnDestroy() => instance = null;
}