Coverage for optimates/combinatorics.py: 0%

128 statements  

« prev     ^ index     » next       coverage.py v7.6.7, created at 2024-11-16 11:19 -0500

1"""This module defines classes for search problems on spaces of standard combinatorial objects (subsets, permutations, etc.).""" 

2 

3from collections.abc import Iterable 

4from dataclasses import dataclass 

5import itertools 

6import math 

7import random 

8 

9from optimates.search import EmptyNeighborSetError, SearchProblem 

10 

11 

12Perm = tuple[int, ...] 

13 

14def num_permutations(n: int, k: int) -> int: 

15 """Gets the number of permutations of n of size k.""" 

16 return math.factorial(n) // math.factorial(n - k) 

17 

18def random_combo2(n: int) -> tuple[int, int]: 

19 """Gets a random ordered pair (i, j), where i != j, and 0 <= i, j < n.""" 

20 i = random.randint(0, n - 1) 

21 j = random.randint(0, n - 2) 

22 if j >= i: 

23 j += 1 

24 return tuple(sorted([i, j])) # type: ignore[return-value] 

25 

26 

27@dataclass 

28class SubsetSearchProblem(SearchProblem[int]): 

29 """A search problem defined on the set of subsets of {0, 1, ..., n - 1}. 

30 Encodes a subset as an n-bit unsigned integer. 

31 A neighbor of a subset is considered to be any subset with "a bit flipped" (i.e. either one element was added or removed from the set).""" 

32 n: int 

33 initial: int = 0 # by default, start off empty 

34 

35 def initial_nodes(self) -> Iterable[int]: 

36 return [self.initial] 

37 

38 def is_solution(self, node: int) -> bool: 

39 return True 

40 

41 def iter_nodes(self) -> Iterable[int]: 

42 return range(2 ** self.n) 

43 

44 def random_node(self) -> int: 

45 return random.randint(0, 2 ** self.n - 1) 

46 

47 def get_neighbors(self, node: int) -> Iterable[int]: 

48 return (node ^ (1 << i) for i in range(self.n)) 

49 

50 def num_neighbors(self, node: int) -> int: 

51 return self.n 

52 

53 def random_neighbor(self, node: int) -> int: 

54 i = random.randint(0, self.n - 1) 

55 return node ^ (1 << i) 

56 

57 

58@dataclass 

59class PermutationSearchProblem(SearchProblem[Perm]): 

60 """A search problem defined on the set of length-n permutations. 

61 A permutation is represented as an integer vector x, where x[i] = j means that i is mapped to j under the permutation. 

62 A neighbor of a permutation is one where a single swap has occurred (n choose 2 total). 

63 If adjacent_only = True, only includes neighbors where the swap is adjacent (n - 1 total).""" 

64 n: int 

65 adjacent_only: bool = False 

66 

67 def initial_nodes(self) -> Iterable[Perm]: 

68 return [tuple(range(self.n))] 

69 

70 def is_solution(self, node: Perm) -> bool: 

71 return True 

72 

73 def iter_nodes(self) -> Iterable[Perm]: 

74 return itertools.permutations(range(self.n)) 

75 

76 def random_node(self) -> Perm: 

77 perm = list(range(self.n)) 

78 random.shuffle(perm) 

79 return tuple(perm) 

80 

81 def get_neighbors(self, node: Perm) -> Iterable[Perm]: 

82 if self.adjacent_only: 

83 pair_gen: Iterable[tuple[int, int]] = ((i, i + 1) for i in range(self.n - 1)) 

84 else: 

85 pair_gen = itertools.combinations(range(self.n), 2) 

86 for (i, j) in pair_gen: 

87 nbr = list(node) 

88 nbr[i], nbr[j] = nbr[j], nbr[i] 

89 yield tuple(nbr) 

90 

91 def num_neighbors(self, node: Perm) -> int: 

92 return (self.n - 1) if self.adjacent_only else math.comb(self.n, 2) 

93 

94 def random_neighbor(self, node: Perm) -> Perm: 

95 if (self.n <= 1): 

96 raise EmptyNeighborSetError() 

97 if self.adjacent_only: 

98 i = random.randint(0, self.n - 2) 

99 j = i + 1 

100 else: 

101 (i, j) = random_combo2(self.n) 

102 nbr = list(node) 

103 nbr[i], nbr[j] = nbr[j], nbr[i] 

104 return tuple(nbr) 

105 

106 

107@dataclass 

108class PermutedSubsequenceSearchProblem(SearchProblem[Perm]): 

109 """A search problem defined on permutations of subsequences of (0, 1, ..., n - 1). 

110 Each node is a sequence (i_1, ..., i_k), where k = 0, ..., n, and each i_j is a distinct element in {0, 1, ..., n - 1}. 

111 A neighbor of a permuted sequence is a sequence that swaps one element of the original sequence with another element of either the sequence or its complement, discards one element, or inserts one element.""" 

112 n: int 

113 

114 def initial_nodes(self) -> Iterable[Perm]: 

115 return [()] 

116 

117 def is_solution(self, node: Perm) -> bool: 

118 return True 

119 

120 def iter_nodes(self) -> Iterable[Perm]: 

121 return itertools.chain.from_iterable(itertools.permutations(range(self.n), k) for k in range(self.n + 1)) 

122 

123 def random_node(self) -> Perm: 

124 # choose k in proportion to the number of permutations of that size 

125 num_perms = [num_permutations(self.n, k) for k in range(self.n + 1)] 

126 k = random.choices(range(self.n + 1), num_perms)[0] 

127 # get a random permutation of n elements, then take the first k 

128 vals = list(range(self.n)) 

129 random.shuffle(vals) 

130 return tuple(vals)[:k] 

131 

132 def get_complement(self, node: Perm) -> list[int]: 

133 """Gets the complement of a node (as a list of integers, ordered, which are not in the subsequence).""" 

134 node_set = set(node) 

135 return [i for i in range(self.n) if (i not in node_set)] 

136 

137 def get_neighbors(self, node: Perm) -> Iterable[Perm]: 

138 k = len(node) 

139 complement = self.get_complement(node) 

140 # internal swaps 

141 for (i, j) in itertools.combinations(range(k), 2): 

142 nbr = list(node) 

143 nbr[i], nbr[j] = nbr[j], nbr[i] 

144 yield tuple(nbr) 

145 # external swaps 

146 for (i, j) in itertools.product(range(k), range(self.n - k)): 

147 nbr = list(node) 

148 nbr[i] = complement[j] 

149 yield tuple(nbr) 

150 # discards 

151 for i in range(k): 

152 yield node[:i] + node[i + 1:] 

153 # insertions 

154 for (i, j) in itertools.product(range(k + 1), range(self.n - k)): 

155 nbr = list(node) 

156 nbr.insert(i, complement[j]) 

157 yield tuple(nbr) 

158 

159 def num_neighbors(self, node: Perm) -> int: 

160 k = len(node) 

161 return math.comb(k, 2) + k * (self.n - k + 1) + (k + 1) * (self.n - k) 

162 

163 def random_neighbor(self, node: Perm) -> Perm: 

164 k = len(node) 

165 num_internal_swaps = math.comb(k, 2) 

166 num_external_swaps = k * (self.n - k) 

167 num_discards = k 

168 num_insertions = (1 + k) * (self.n - k) 

169 r = random.choices(range(4), [num_internal_swaps, num_external_swaps, num_discards, num_insertions])[0] 

170 nbr = list(node) 

171 if (r == 0): # internal swap 

172 (i, j) = random_combo2(k) 

173 nbr[i], nbr[j] = nbr[j], nbr[i] 

174 elif (r == 1): # external swap 

175 i = random.randint(0, k - 1) 

176 j = random.randint(0, self.n - k - 1) 

177 nbr[i] = self.get_complement(node)[j] 

178 elif (r == 2): # discard 

179 i = random.randint(0, k - 1) 

180 nbr = nbr[:i] + nbr[i + 1:] 

181 else: # insert 

182 i = random.randint(0, k) 

183 j = random.randint(0, self.n - k - 1) 

184 nbr.insert(i, self.get_complement(node)[j]) 

185 return tuple(nbr)