diff --git a/docs/chapter_array_and_linkedlist/array.md b/docs/chapter_array_and_linkedlist/array.md index 9d492a1a3..454d4489e 100755 --- a/docs/chapter_array_and_linkedlist/array.md +++ b/docs/chapter_array_and_linkedlist/array.md @@ -134,7 +134,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   访问元素 @@ -320,7 +320,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 3.   插入元素 @@ -516,7 +516,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 4.   删除元素 @@ -686,7 +686,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
总的来看,数组的插入与删除操作有以下缺点。 @@ -933,7 +933,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 6.   查找元素 @@ -1115,7 +1115,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 7.   扩容数组 @@ -1345,7 +1345,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 4.1.2   数组的优点与局限性 diff --git a/docs/chapter_array_and_linkedlist/linked_list.md b/docs/chapter_array_and_linkedlist/linked_list.md index d81fd6bbc..79aefd68a 100755 --- a/docs/chapter_array_and_linkedlist/linked_list.md +++ b/docs/chapter_array_and_linkedlist/linked_list.md @@ -421,7 +421,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` 和 `nums[1]` 等,而链表是由多个独立的节点对象组成的。**我们通常将头节点当作链表的代称**,比如以上代码中的链表可记作链表 `n0` 。 @@ -582,7 +582,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 3.   删除节点 @@ -785,7 +785,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 4.   访问节点 @@ -989,7 +989,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 5.   查找节点 @@ -1215,7 +1215,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 4.2.2   数组 vs. 链表 diff --git a/docs/chapter_array_and_linkedlist/list.md b/docs/chapter_array_and_linkedlist/list.md index a1ab8a92b..faf2d2caa 100755 --- a/docs/chapter_array_and_linkedlist/list.md +++ b/docs/chapter_array_and_linkedlist/list.md @@ -153,7 +153,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   访问元素 @@ -285,7 +285,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 3.   插入与删除元素 @@ -540,7 +540,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 4.   遍历列表 @@ -742,7 +742,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 5.   拼接列表 @@ -854,7 +854,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 6.   排序列表 @@ -953,7 +953,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 4.3.2   列表实现 @@ -2297,5 +2297,5 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
diff --git a/docs/chapter_backtracking/backtracking_algorithm.md b/docs/chapter_backtracking/backtracking_algorithm.md index c7e167192..e74160c58 100644 --- a/docs/chapter_backtracking/backtracking_algorithm.md +++ b/docs/chapter_backtracking/backtracking_algorithm.md @@ -234,7 +234,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
![在前序遍历中搜索节点](backtracking_algorithm.assets/preorder_find_nodes.png){ class="animation-figure" } @@ -545,7 +545,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
在每次“尝试”中,我们通过将当前节点添加进 `path` 来记录路径;而在“回退”前,我们需要将该节点从 `path` 中弹出,**以恢复本次尝试之前的状态**。 @@ -899,7 +899,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
“剪枝”是一个非常形象的名词。如图 13-3 所示,在搜索过程中,**我们“剪掉”了不满足约束条件的搜索分支**,避免许多无意义的尝试,从而提高了搜索效率。 @@ -1926,7 +1926,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
根据题意,我们在找到值为 $7$ 的节点后应该继续搜索,**因此需要将记录解之后的 `return` 语句删除**。图 13-4 对比了保留或删除 `return` 语句的搜索过程。 diff --git a/docs/chapter_backtracking/n_queens_problem.md b/docs/chapter_backtracking/n_queens_problem.md index 8aec7c995..de8acb336 100644 --- a/docs/chapter_backtracking/n_queens_problem.md +++ b/docs/chapter_backtracking/n_queens_problem.md @@ -746,7 +746,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
逐行放置 $n$ 次,考虑列约束,则从第一行到最后一行分别有 $n$、$n-1$、$\dots$、$2$、$1$ 个选择,使用 $O(n!)$ 时间。当记录解时,需要复制矩阵 `state` 并添加进 `res` ,复制操作使用 $O(n^2)$ 时间。因此,**总体时间复杂度为 $O(n! \cdot n^2)$** 。实际上,根据对角线约束的剪枝也能够大幅缩小搜索空间,因而搜索效率往往优于以上时间复杂度。 diff --git a/docs/chapter_backtracking/permutations_problem.md b/docs/chapter_backtracking/permutations_problem.md index 873340e29..f4cd8065c 100644 --- a/docs/chapter_backtracking/permutations_problem.md +++ b/docs/chapter_backtracking/permutations_problem.md @@ -540,7 +540,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 13.2.2   考虑相等元素的情况 @@ -1087,7 +1087,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
假设元素两两之间互不相同,则 $n$ 个元素共有 $n!$ 种排列(阶乘);在记录结果时,需要复制长度为 $n$ 的列表,使用 $O(n)$ 时间。**因此时间复杂度为 $O(n!n)$** 。 diff --git a/docs/chapter_backtracking/subset_sum_problem.md b/docs/chapter_backtracking/subset_sum_problem.md index eef470afb..f57fbb330 100644 --- a/docs/chapter_backtracking/subset_sum_problem.md +++ b/docs/chapter_backtracking/subset_sum_problem.md @@ -503,7 +503,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
向以上代码输入数组 $[3, 4, 5]$ 和目标元素 $9$ ,输出结果为 $[3, 3, 3], [4, 5], [5, 4]$ 。**虽然成功找出了所有和为 $9$ 的子集,但其中存在重复的子集 $[4, 5]$ 和 $[5, 4]$** 。 @@ -1064,7 +1064,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
图 13-12 所示为将数组 $[3, 4, 5]$ 和目标元素 $9$ 输入以上代码后的整体回溯过程。 @@ -1675,7 +1675,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
图 13-14 展示了数组 $[4, 4, 5]$ 和目标元素 $9$ 的回溯过程,共包含四种剪枝操作。请你将图示与代码注释相结合,理解整个搜索过程,以及每种剪枝操作是如何工作的。 diff --git a/docs/chapter_computational_complexity/iteration_and_recursion.md b/docs/chapter_computational_complexity/iteration_and_recursion.md index 699f50d80..ec36d9bab 100644 --- a/docs/chapter_computational_complexity/iteration_and_recursion.md +++ b/docs/chapter_computational_complexity/iteration_and_recursion.md @@ -200,7 +200,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
图 2-1 是该求和函数的流程框图。 @@ -430,7 +430,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
**`while` 循环比 `for` 循环的自由度更高**。在 `while` 循环中,我们可以自由地设计条件变量的初始化和更新步骤。 @@ -675,7 +675,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
总的来说,**`for` 循环的代码更加紧凑,`while` 循环更加灵活**,两者都可以实现迭代结构。选择使用哪一个应该根据特定问题的需求来决定。 @@ -910,7 +910,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
图 2-2 是该嵌套循环的流程框图。 @@ -1133,7 +1133,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
图 2-3 展示了该函数的递归过程。 @@ -1346,7 +1346,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
尾递归的执行过程如图 2-5 所示。对比普通递归和尾递归,两者的求和操作的执行点是不同的。 @@ -1572,7 +1572,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
观察以上代码,我们在函数内递归调用了两个函数,**这意味着从一个调用产生了两个调用分支**。如图 2-6 所示,这样不断递归调用下去,最终将产生一棵层数为 $n$ 的递归树(recursion tree)。 @@ -1917,7 +1917,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
观察以上代码,当递归转化为迭代后,代码变得更加复杂了。尽管迭代和递归在很多情况下可以互相转化,但不一定值得这样做,有以下两点原因。 diff --git a/docs/chapter_computational_complexity/space_complexity.md b/docs/chapter_computational_complexity/space_complexity.md index c0aa74d2d..63d6be7b6 100755 --- a/docs/chapter_computational_complexity/space_complexity.md +++ b/docs/chapter_computational_complexity/space_complexity.md @@ -1179,7 +1179,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   线性阶 $O(n)$ {data-toc-label="2.   线性阶"} @@ -1457,7 +1457,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
如图 2-17 所示,此函数的递归深度为 $n$ ,即同时存在 $n$ 个未返回的 `linear_recur()` 函数,使用 $O(n)$ 大小的栈帧空间: @@ -1617,7 +1617,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
![递归函数产生的线性阶空间复杂度](space_complexity.assets/space_complexity_recursive_linear.png){ class="animation-figure" } @@ -1850,7 +1850,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
如图 2-18 所示,该函数的递归深度为 $n$ ,在每个递归函数中都初始化了一个数组,长度分别为 $n$、$n-1$、$\dots$、$2$、$1$ ,平均长度为 $n / 2$ ,因此总体占用 $O(n^2)$ 空间: @@ -2031,7 +2031,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
![递归函数产生的平方阶空间复杂度](space_complexity.assets/space_complexity_recursive_quadratic.png){ class="animation-figure" } @@ -2225,7 +2225,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
![满二叉树产生的指数阶空间复杂度](space_complexity.assets/space_complexity_exponential.png){ class="animation-figure" } diff --git a/docs/chapter_computational_complexity/time_complexity.md b/docs/chapter_computational_complexity/time_complexity.md index cdf1711ae..342907aec 100755 --- a/docs/chapter_computational_complexity/time_complexity.md +++ b/docs/chapter_computational_complexity/time_complexity.md @@ -1194,7 +1194,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   线性阶 $O(n)$ {data-toc-label="2.   线性阶"} @@ -1360,7 +1360,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
遍历数组和遍历链表等操作的时间复杂度均为 $O(n)$ ,其中 $n$ 为数组或链表的长度: @@ -1549,7 +1549,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
值得注意的是,**输入数据大小 $n$ 需根据输入数据的类型来具体确定**。比如在第一个示例中,变量 $n$ 为输入数据大小;在第二个示例中,数组长度 $n$ 为数据大小。 @@ -1767,7 +1767,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
图 2-10 对比了常数阶、线性阶和平方阶三种时间复杂度。 @@ -2076,7 +2076,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
### 4.   指数阶 $O(2^n)$ {data-toc-label="4.   指数阶"} @@ -2325,7 +2325,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
![指数阶的时间复杂度](time_complexity.assets/time_complexity_exponential.png){ class="animation-figure" } @@ -2477,7 +2477,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。 @@ -2674,7 +2674,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
![对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic.png){ class="animation-figure" } @@ -2825,7 +2825,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。 @@ -3039,7 +3039,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
图 2-13 展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为 $n$ ,树共有 $\log_2 n + 1$ 层,因此时间复杂度为 $O(n \log n)$ 。 @@ -3265,7 +3265,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
![阶乘阶的时间复杂度](time_complexity.assets/time_complexity_factorial.png){ class="animation-figure" } @@ -3659,7 +3659,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。**而最差时间复杂度更为实用,因为它给出了一个效率安全值**,让我们可以放心地使用算法。 diff --git a/docs/chapter_data_structure/basic_data_types.md b/docs/chapter_data_structure/basic_data_types.md index 1db5460c6..0dd6e7a02 100644 --- a/docs/chapter_data_structure/basic_data_types.md +++ b/docs/chapter_data_structure/basic_data_types.md @@ -180,5 +180,5 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
diff --git a/docs/chapter_divide_and_conquer/binary_search_recur.md b/docs/chapter_divide_and_conquer/binary_search_recur.md index f7ce7677d..2c19c4c4b 100644 --- a/docs/chapter_divide_and_conquer/binary_search_recur.md +++ b/docs/chapter_divide_and_conquer/binary_search_recur.md @@ -452,5 +452,5 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
diff --git a/docs/chapter_divide_and_conquer/build_binary_tree_problem.md b/docs/chapter_divide_and_conquer/build_binary_tree_problem.md index b167f7e39..d5ba06039 100644 --- a/docs/chapter_divide_and_conquer/build_binary_tree_problem.md +++ b/docs/chapter_divide_and_conquer/build_binary_tree_problem.md @@ -514,7 +514,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
图 12-8 展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(引用)是在向上“归”的过程中建立的。 diff --git a/docs/chapter_divide_and_conquer/hanota_problem.md b/docs/chapter_divide_and_conquer/hanota_problem.md index e796cfd8a..92bafdec3 100644 --- a/docs/chapter_divide_and_conquer/hanota_problem.md +++ b/docs/chapter_divide_and_conquer/hanota_problem.md @@ -544,7 +544,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
如图 12-15 所示,汉诺塔问题形成一棵高度为 $n$ 的递归树,每个节点代表一个子问题,对应一个开启的 `dfs()` 函数,**因此时间复杂度为 $O(2^n)$ ,空间复杂度为 $O(n)$** 。 diff --git a/docs/chapter_dynamic_programming/dp_problem_features.md b/docs/chapter_dynamic_programming/dp_problem_features.md index db17d2f6a..10c4ff14f 100644 --- a/docs/chapter_dynamic_programming/dp_problem_features.md +++ b/docs/chapter_dynamic_programming/dp_problem_features.md @@ -320,7 +320,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
图 14-7 展示了以上代码的动态规划过程。 @@ -583,7 +583,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
## 14.2.2   无后效性 @@ -944,7 +944,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
在上面的案例中,由于仅需多考虑前面一个状态,因此我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。 diff --git a/docs/chapter_dynamic_programming/dp_solution_pipeline.md b/docs/chapter_dynamic_programming/dp_solution_pipeline.md index e1644d7d8..d6441376a 100644 --- a/docs/chapter_dynamic_programming/dp_solution_pipeline.md +++ b/docs/chapter_dynamic_programming/dp_solution_pipeline.md @@ -385,7 +385,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
图 14-14 给出了以 $dp[2, 1]$ 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 `grid` 的尺寸变大而急剧增多。 @@ -744,7 +744,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
如图 14-15 所示,在引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 $O(nm)$ 。 @@ -1119,7 +1119,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
图 14-16 展示了最小路径和的状态转移过程,其遍历了整个网格,**因此时间复杂度为 $O(nm)$** 。 @@ -1507,5 +1507,5 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
diff --git a/docs/chapter_dynamic_programming/edit_distance_problem.md b/docs/chapter_dynamic_programming/edit_distance_problem.md index c670a590d..57bf0dd9c 100644 --- a/docs/chapter_dynamic_programming/edit_distance_problem.md +++ b/docs/chapter_dynamic_programming/edit_distance_problem.md @@ -479,7 +479,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
如图 14-30 所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作填写一个二维网格的过程。 @@ -968,5 +968,5 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
diff --git a/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md b/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md index 6ae1e0455..ec62eed2f 100644 --- a/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md +++ b/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -422,7 +422,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 14.1.1   方法一:暴力搜索 @@ -697,7 +697,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
图 14-3 展示了暴力搜索形成的递归树。对于问题 $dp[n]$ ,其递归树的深度为 $n$ ,时间复杂度为 $O(2^n)$ 。指数阶属于爆炸式增长,如果我们输入一个比较大的 $n$ ,则会陷入漫长的等待之中。 @@ -1064,7 +1064,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
观察图 14-4 ,**经过记忆化处理后,所有重叠子问题都只需计算一次,时间复杂度优化至 $O(n)$** ,这是一个巨大的飞跃。 @@ -1340,7 +1340,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
图 14-5 模拟了以上代码的执行过程。 @@ -1577,7 +1577,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
观察以上代码,由于省去了数组 `dp` 占用的空间,因此空间复杂度从 $O(n)$ 降至 $O(1)$ 。 diff --git a/docs/chapter_dynamic_programming/knapsack_problem.md b/docs/chapter_dynamic_programming/knapsack_problem.md index c3c4b7b8f..05a16ef0c 100644 --- a/docs/chapter_dynamic_programming/knapsack_problem.md +++ b/docs/chapter_dynamic_programming/knapsack_problem.md @@ -340,7 +340,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
如图 14-18 所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 $O(2^n)$ 。 @@ -708,7 +708,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
图 14-19 展示了在记忆化搜索中被剪掉的搜索分支。 @@ -1059,7 +1059,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
如图 14-20 所示,时间复杂度和空间复杂度都由数组 `dp` 大小决定,即 $O(n \times cap)$ 。 @@ -1441,5 +1441,5 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
diff --git a/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md b/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md index e1caa811f..5c90a8272 100644 --- a/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -373,7 +373,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
### 3.   空间优化 @@ -747,7 +747,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
## 14.5.2   零钱兑换问题 @@ -1194,7 +1194,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
图 14-25 展示了零钱兑换的动态规划过程,和完全背包问题非常相似。 @@ -1609,7 +1609,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
## 14.5.3   零钱兑换问题 II @@ -2011,7 +2011,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
### 3.   空间优化 @@ -2347,5 +2347,5 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
diff --git a/docs/chapter_graph/graph_operations.md b/docs/chapter_graph/graph_operations.md index 694ee0840..5419793f0 100644 --- a/docs/chapter_graph/graph_operations.md +++ b/docs/chapter_graph/graph_operations.md @@ -1208,7 +1208,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 9.2.2   基于邻接表的实现 @@ -2366,7 +2366,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 9.2.3   效率对比 diff --git a/docs/chapter_graph/graph_traversal.md b/docs/chapter_graph/graph_traversal.md index 8b90550e3..9a67da79e 100644 --- a/docs/chapter_graph/graph_traversal.md +++ b/docs/chapter_graph/graph_traversal.md @@ -477,7 +477,7 @@ BFS 通常借助队列来实现,代码如下所示。队列具有“先入先 ??? pythontutor "可视化运行" -
+
全屏观看 >
代码相对抽象,建议对照图 9-10 来加深理解。 @@ -937,7 +937,7 @@ BFS 通常借助队列来实现,代码如下所示。队列具有“先入先 ??? pythontutor "可视化运行" -
+
全屏观看 >
深度优先遍历的算法流程如图 9-12 所示。 diff --git a/docs/chapter_greedy/fractional_knapsack_problem.md b/docs/chapter_greedy/fractional_knapsack_problem.md index 62d03e498..ce4a580db 100644 --- a/docs/chapter_greedy/fractional_knapsack_problem.md +++ b/docs/chapter_greedy/fractional_knapsack_problem.md @@ -533,7 +533,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
内置排序算法的时间复杂度通常为 $O(n \log n)$ ,空间复杂度通常为 $O(\log n)$ 或 $O(n)$ ,取决于编程语言的具体实现。 diff --git a/docs/chapter_greedy/greedy_algorithm.md b/docs/chapter_greedy/greedy_algorithm.md index 78de24e60..d2a201cf6 100644 --- a/docs/chapter_greedy/greedy_algorithm.md +++ b/docs/chapter_greedy/greedy_algorithm.md @@ -332,7 +332,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
你可能会不由地发出感叹:So clean !贪心算法仅用约十行代码就解决了零钱兑换问题。 diff --git a/docs/chapter_greedy/max_capacity_problem.md b/docs/chapter_greedy/max_capacity_problem.md index 07f37414e..67dad0560 100644 --- a/docs/chapter_greedy/max_capacity_problem.md +++ b/docs/chapter_greedy/max_capacity_problem.md @@ -423,7 +423,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
### 3.   正确性证明 diff --git a/docs/chapter_greedy/max_product_cutting_problem.md b/docs/chapter_greedy/max_product_cutting_problem.md index c3599112d..d37f2bad0 100644 --- a/docs/chapter_greedy/max_product_cutting_problem.md +++ b/docs/chapter_greedy/max_product_cutting_problem.md @@ -388,7 +388,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
![最大切分乘积的计算方法](max_product_cutting_problem.assets/max_product_cutting_greedy_calculation.png){ class="animation-figure" } diff --git a/docs/chapter_hashing/hash_algorithm.md b/docs/chapter_hashing/hash_algorithm.md index b41085712..68045ed1a 100644 --- a/docs/chapter_hashing/hash_algorithm.md +++ b/docs/chapter_hashing/hash_algorithm.md @@ -644,7 +644,7 @@ index = hash(key) % capacity ??? pythontutor "可视化运行" -
+
全屏观看 >
观察发现,每种哈希算法的最后一步都是对大质数 $1000000007$ 取模,以确保哈希值在合适的范围内。值得思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?这是一个有趣的问题。 @@ -1002,7 +1002,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
在许多编程语言中,**只有不可变对象才可作为哈希表的 `key`** 。假如我们将列表(动态数组)作为 `key` ,当列表的内容发生变化时,它的哈希值也随之改变,我们就无法在哈希表中查询到原先的 `value` 了。 diff --git a/docs/chapter_hashing/hash_collision.md b/docs/chapter_hashing/hash_collision.md index 7373b137c..b95693962 100644 --- a/docs/chapter_hashing/hash_collision.md +++ b/docs/chapter_hashing/hash_collision.md @@ -1519,7 +1519,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
值得注意的是,当链表很长时,查询效率 $O(n)$ 很差。**此时可以将链表转换为“AVL 树”或“红黑树”**,从而将查询操作的时间复杂度优化至 $O(\log n)$ 。 diff --git a/docs/chapter_hashing/hash_map.md b/docs/chapter_hashing/hash_map.md index 9b60dbfc0..10f630918 100755 --- a/docs/chapter_hashing/hash_map.md +++ b/docs/chapter_hashing/hash_map.md @@ -325,7 +325,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
哈希表有三种常用的遍历方式:遍历键值对、遍历键和遍历值。示例代码如下: @@ -547,7 +547,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 6.1.2   哈希表简单实现 @@ -1755,7 +1755,7 @@ index = hash(key) % capacity ??? pythontutor "可视化运行" -
+
全屏观看 >
## 6.1.3   哈希冲突与扩容 diff --git a/docs/chapter_heap/build_heap.md b/docs/chapter_heap/build_heap.md index a1f8c10c3..e12c48ba9 100644 --- a/docs/chapter_heap/build_heap.md +++ b/docs/chapter_heap/build_heap.md @@ -324,7 +324,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 8.2.3   复杂度分析 diff --git a/docs/chapter_heap/heap.md b/docs/chapter_heap/heap.md index f14335e48..361c00b8f 100644 --- a/docs/chapter_heap/heap.md +++ b/docs/chapter_heap/heap.md @@ -420,7 +420,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 8.1.2   堆的实现 @@ -808,7 +808,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 3.   元素入堆 @@ -1213,7 +1213,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 4.   堆顶元素出堆 @@ -1770,7 +1770,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 8.1.3   堆的常见应用 diff --git a/docs/chapter_heap/top_k.md b/docs/chapter_heap/top_k.md index 834ce5d8a..f20fc9d64 100644 --- a/docs/chapter_heap/top_k.md +++ b/docs/chapter_heap/top_k.md @@ -463,7 +463,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
总共执行了 $n$ 轮入堆和出堆,堆的最大长度为 $k$ ,因此时间复杂度为 $O(n \log k)$ 。该方法的效率很高,当 $k$ 较小时,时间复杂度趋向 $O(n)$ ;当 $k$ 较大时,时间复杂度不会超过 $O(n \log n)$ 。 diff --git a/docs/chapter_searching/binary_search.md b/docs/chapter_searching/binary_search.md index 8b6e64657..f4cdf1b3f 100755 --- a/docs/chapter_searching/binary_search.md +++ b/docs/chapter_searching/binary_search.md @@ -364,7 +364,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
**时间复杂度为 $O(\log n)$** :在二分循环中,区间每轮缩小一半,因此循环次数为 $\log_2 n$ 。 @@ -688,7 +688,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
如图 10-3 所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。 diff --git a/docs/chapter_searching/binary_search_edge.md b/docs/chapter_searching/binary_search_edge.md index 27076dcb0..ee8161648 100644 --- a/docs/chapter_searching/binary_search_edge.md +++ b/docs/chapter_searching/binary_search_edge.md @@ -226,7 +226,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 10.3.2   查找右边界 @@ -481,7 +481,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   转化为查找元素 diff --git a/docs/chapter_searching/binary_search_insertion.md b/docs/chapter_searching/binary_search_insertion.md index aaa016f2f..da6ee83dc 100644 --- a/docs/chapter_searching/binary_search_insertion.md +++ b/docs/chapter_searching/binary_search_insertion.md @@ -317,7 +317,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 10.2.2   存在重复元素的情况 @@ -662,7 +662,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
!!! tip diff --git a/docs/chapter_searching/replace_linear_by_hashing.md b/docs/chapter_searching/replace_linear_by_hashing.md index 6225bc7d2..e250d7ac6 100755 --- a/docs/chapter_searching/replace_linear_by_hashing.md +++ b/docs/chapter_searching/replace_linear_by_hashing.md @@ -243,7 +243,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
此方法的时间复杂度为 $O(n^2)$ ,空间复杂度为 $O(1)$ ,在大数据量下非常耗时。 @@ -538,7 +538,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
此方法通过哈希查找将时间复杂度从 $O(n^2)$ 降至 $O(n)$ ,大幅提升运行效率。 diff --git a/docs/chapter_sorting/bubble_sort.md b/docs/chapter_sorting/bubble_sort.md index 5888bbd7f..8f74cc2a1 100755 --- a/docs/chapter_sorting/bubble_sort.md +++ b/docs/chapter_sorting/bubble_sort.md @@ -292,7 +292,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.3.2   效率优化 @@ -597,7 +597,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.3.3   算法特性 diff --git a/docs/chapter_sorting/bucket_sort.md b/docs/chapter_sorting/bucket_sort.md index cad768885..2d30f9e50 100644 --- a/docs/chapter_sorting/bucket_sort.md +++ b/docs/chapter_sorting/bucket_sort.md @@ -439,7 +439,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.8.2   算法特性 diff --git a/docs/chapter_sorting/counting_sort.md b/docs/chapter_sorting/counting_sort.md index eebe7e919..caf21c34a 100644 --- a/docs/chapter_sorting/counting_sort.md +++ b/docs/chapter_sorting/counting_sort.md @@ -363,7 +363,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
!!! note "计数排序与桶排序的联系" @@ -879,7 +879,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.9.3   算法特性 diff --git a/docs/chapter_sorting/heap_sort.md b/docs/chapter_sorting/heap_sort.md index 274fe3310..2aff3324f 100644 --- a/docs/chapter_sorting/heap_sort.md +++ b/docs/chapter_sorting/heap_sort.md @@ -614,7 +614,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.7.2   算法特性 diff --git a/docs/chapter_sorting/insertion_sort.md b/docs/chapter_sorting/insertion_sort.md index 8444051ef..9e6cb6d64 100755 --- a/docs/chapter_sorting/insertion_sort.md +++ b/docs/chapter_sorting/insertion_sort.md @@ -272,7 +272,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.4.2   算法特性 diff --git a/docs/chapter_sorting/merge_sort.md b/docs/chapter_sorting/merge_sort.md index 2c15f8c18..20c5b6143 100755 --- a/docs/chapter_sorting/merge_sort.md +++ b/docs/chapter_sorting/merge_sort.md @@ -681,7 +681,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.6.2   算法特性 diff --git a/docs/chapter_sorting/quick_sort.md b/docs/chapter_sorting/quick_sort.md index 0d6c486c4..038016381 100755 --- a/docs/chapter_sorting/quick_sort.md +++ b/docs/chapter_sorting/quick_sort.md @@ -368,7 +368,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.5.1   算法流程 @@ -595,7 +595,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.5.2   算法特性 @@ -1084,7 +1084,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.5.5   递归深度优化 @@ -1373,5 +1373,5 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
diff --git a/docs/chapter_sorting/radix_sort.md b/docs/chapter_sorting/radix_sort.md index e6fe97061..b60e993ef 100644 --- a/docs/chapter_sorting/radix_sort.md +++ b/docs/chapter_sorting/radix_sort.md @@ -718,7 +718,7 @@ $$ ??? pythontutor "可视化运行" -
+
全屏观看 >
!!! question "为什么从最低位开始排序?" diff --git a/docs/chapter_sorting/selection_sort.md b/docs/chapter_sorting/selection_sort.md index 6fc485712..7b3fe9767 100644 --- a/docs/chapter_sorting/selection_sort.md +++ b/docs/chapter_sorting/selection_sort.md @@ -326,7 +326,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 11.2.1   算法特性 diff --git a/docs/chapter_stack_and_queue/deque.md b/docs/chapter_stack_and_queue/deque.md index 34c075f35..2f85fac0d 100644 --- a/docs/chapter_stack_and_queue/deque.md +++ b/docs/chapter_stack_and_queue/deque.md @@ -395,7 +395,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 5.3.2   双向队列实现 * diff --git a/docs/chapter_stack_and_queue/queue.md b/docs/chapter_stack_and_queue/queue.md index 256d2d1cf..25eafba2c 100755 --- a/docs/chapter_stack_and_queue/queue.md +++ b/docs/chapter_stack_and_queue/queue.md @@ -368,7 +368,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 5.2.2   队列实现 @@ -1313,7 +1313,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   基于数组的实现 @@ -2288,7 +2288,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
以上实现的队列仍然具有局限性:其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的读者可以尝试自行实现。 diff --git a/docs/chapter_stack_and_queue/stack.md b/docs/chapter_stack_and_queue/stack.md index c1566eda9..900f97d01 100755 --- a/docs/chapter_stack_and_queue/stack.md +++ b/docs/chapter_stack_and_queue/stack.md @@ -361,7 +361,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 5.1.2   栈的实现 @@ -1162,7 +1162,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   基于数组的实现 @@ -1804,7 +1804,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 5.1.3   两种实现对比 diff --git a/docs/chapter_tree/array_representation_of_tree.md b/docs/chapter_tree/array_representation_of_tree.md index fb9366c2e..04302eee8 100644 --- a/docs/chapter_tree/array_representation_of_tree.md +++ b/docs/chapter_tree/array_representation_of_tree.md @@ -1330,7 +1330,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
## 7.3.3   优点与局限性 diff --git a/docs/chapter_tree/binary_search_tree.md b/docs/chapter_tree/binary_search_tree.md index 9efa24876..3401248c2 100755 --- a/docs/chapter_tree/binary_search_tree.md +++ b/docs/chapter_tree/binary_search_tree.md @@ -340,7 +340,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   插入节点 @@ -804,7 +804,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
与查找节点相同,插入节点使用 $O(\log n)$ 时间。 @@ -1590,7 +1590,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 4.   中序遍历有序 diff --git a/docs/chapter_tree/binary_tree.md b/docs/chapter_tree/binary_tree.md index c563fe99a..7434da2fc 100644 --- a/docs/chapter_tree/binary_tree.md +++ b/docs/chapter_tree/binary_tree.md @@ -459,7 +459,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   插入与删除节点 @@ -628,7 +628,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
!!! tip diff --git a/docs/chapter_tree/binary_tree_traversal.md b/docs/chapter_tree/binary_tree_traversal.md index 9dbbc550a..40a8b8752 100755 --- a/docs/chapter_tree/binary_tree_traversal.md +++ b/docs/chapter_tree/binary_tree_traversal.md @@ -336,7 +336,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
### 2.   复杂度分析 @@ -821,7 +821,7 @@ comments: true ??? pythontutor "可视化运行" -
+
全屏观看 >
!!! tip diff --git a/en/docs/chapter_array_and_linkedlist/array.md b/en/docs/chapter_array_and_linkedlist/array.md index 118496c00..5cb2b7452 100755 --- a/en/docs/chapter_array_and_linkedlist/array.md +++ b/en/docs/chapter_array_and_linkedlist/array.md @@ -134,7 +134,7 @@ We can choose between two array initialization methods based on our needs: with ??? pythontutor "Code Visualization" -
+
Full Screen >
### 2.   Accessing Elements diff --git a/en/docs/chapter_array_and_linkedlist/linked_list.md b/en/docs/chapter_array_and_linkedlist/linked_list.md index fc442b41f..6b8465313 100755 --- a/en/docs/chapter_array_and_linkedlist/linked_list.md +++ b/en/docs/chapter_array_and_linkedlist/linked_list.md @@ -421,7 +421,7 @@ Building a linked list involves two steps: first, initializing each node object; ??? pythontutor "Code Visualization" -
+
Full Screen >
An array is a single variable; for example, an array `nums` contains elements `nums[0]`, `nums[1]`, and so on. A linked list, by contrast, is composed of multiple independent node objects. **We usually use the head node as a stand-in for the entire linked list**; for example, the linked list in the above code can be referred to as linked list `n0`. diff --git a/en/docs/chapter_array_and_linkedlist/list.md b/en/docs/chapter_array_and_linkedlist/list.md index 34b8b6125..96bed8f47 100755 --- a/en/docs/chapter_array_and_linkedlist/list.md +++ b/en/docs/chapter_array_and_linkedlist/list.md @@ -153,7 +153,7 @@ We typically initialize a list in one of two ways: empty or with predefined valu ??? pythontutor "Code Visualization" -
+
Full Screen >
### 2.   Access Elements @@ -285,7 +285,7 @@ Since a list is essentially an array, we can access and update elements in $O(1) ??? pythontutor "Code Visualization" -
+
Full Screen >
### 3.   Insert and Delete Elements @@ -540,7 +540,7 @@ Compared to arrays, lists can freely add and delete elements. Adding an element ??? pythontutor "Code Visualization" -
+
Full Screen >
### 4.   Traverse a List @@ -742,7 +742,7 @@ Like arrays, lists can be traversed by index or by directly iterating through el ??? pythontutor "Code Visualization" -
+
Full Screen >
### 5.   Concatenate Lists @@ -854,7 +854,7 @@ Given a new list `nums1`, we can concatenate it to the end of the original list. ??? pythontutor "Code Visualization" -
+
Full Screen >
### 6.   Sort a List @@ -953,7 +953,7 @@ After sorting a list, we can use "binary search" and "two-pointer" algorithms, w ??? pythontutor "Code Visualization" -
+
Full Screen >
## 4.3.2   List Implementation diff --git a/en/docs/chapter_data_structure/basic_data_types.md b/en/docs/chapter_data_structure/basic_data_types.md index e22730fcc..7faca2a3e 100644 --- a/en/docs/chapter_data_structure/basic_data_types.md +++ b/en/docs/chapter_data_structure/basic_data_types.md @@ -180,5 +180,5 @@ In other words, **basic data types provide the "content type" of data, while dat ??? pythontutor "Visualized Execution" -
+
Full Screen >
diff --git a/en/docs/chapter_hashing/hash_algorithm.md b/en/docs/chapter_hashing/hash_algorithm.md index cca51bef7..6479011d7 100644 --- a/en/docs/chapter_hashing/hash_algorithm.md +++ b/en/docs/chapter_hashing/hash_algorithm.md @@ -997,7 +997,7 @@ We know that hash table keys can be integers, floating-point numbers, strings, a ??? pythontutor "Visualized Execution" -
+
Full Screen >
In many programming languages, **only immutable objects can serve as the `key` in a hash table**. If we use a list (dynamic array) as a `key`, when the contents of the list change, its hash value also changes, and we would no longer be able to find the original `value` in the hash table. diff --git a/en/docs/chapter_hashing/hash_map.md b/en/docs/chapter_hashing/hash_map.md index f718c554b..046ae5279 100755 --- a/en/docs/chapter_hashing/hash_map.md +++ b/en/docs/chapter_hashing/hash_map.md @@ -325,7 +325,7 @@ Common operations on hash tables include: initialization, query operations, addi ??? pythontutor "Visualized Execution" -
+
Full Screen >
There are three common ways to traverse a hash table: traversing key-value pairs, traversing keys, and traversing values. Example code is as follows: @@ -547,7 +547,7 @@ There are three common ways to traverse a hash table: traversing key-value pairs ??? pythontutor "Visualized Execution" -
+
Full Screen >
## 6.1.2   Simple Hash Table Implementation diff --git a/en/docs/chapter_heap/heap.md b/en/docs/chapter_heap/heap.md index f1faff2be..14aee70bd 100644 --- a/en/docs/chapter_heap/heap.md +++ b/en/docs/chapter_heap/heap.md @@ -420,7 +420,7 @@ Similar to "ascending order" and "descending order" in sorting algorithms, we ca ??? pythontutor "Code Visualization" -
+
Full Screen >
## 8.1.2   Implementation of the Heap diff --git a/en/docs/chapter_stack_and_queue/deque.md b/en/docs/chapter_stack_and_queue/deque.md index 3a7353e5d..631d4d01c 100644 --- a/en/docs/chapter_stack_and_queue/deque.md +++ b/en/docs/chapter_stack_and_queue/deque.md @@ -395,7 +395,7 @@ Similarly, we can directly use the deque classes provided by the programming lan ??? pythontutor "Code Visualization" -
+
Full Screen >
## 5.3.2   Deque Implementation * diff --git a/en/docs/chapter_stack_and_queue/queue.md b/en/docs/chapter_stack_and_queue/queue.md index 7f4a06907..2f6c54a2f 100755 --- a/en/docs/chapter_stack_and_queue/queue.md +++ b/en/docs/chapter_stack_and_queue/queue.md @@ -368,7 +368,7 @@ We can directly use the queue classes provided by the programming language: ??? pythontutor "Code Visualization" -
+
Full Screen >
## 5.2.2   Queue Implementation diff --git a/en/docs/chapter_stack_and_queue/stack.md b/en/docs/chapter_stack_and_queue/stack.md index 48f0e61bf..84ec2c01f 100755 --- a/en/docs/chapter_stack_and_queue/stack.md +++ b/en/docs/chapter_stack_and_queue/stack.md @@ -361,7 +361,7 @@ Typically, we can directly use the built-in stack class provided by the programm ??? pythontutor "Code Visualization" -
+
Full Screen >
## 5.1.2   Stack Implementation diff --git a/en/docs/chapter_tree/binary_tree.md b/en/docs/chapter_tree/binary_tree.md index 9ab02878a..920c11c65 100644 --- a/en/docs/chapter_tree/binary_tree.md +++ b/en/docs/chapter_tree/binary_tree.md @@ -459,7 +459,7 @@ Similar to a linked list, the initialization of a binary tree involves first cre ??? pythontutor "Code Visualization" -
+
Full Screen >
### 2.   Inserting and Removing Nodes @@ -624,7 +624,7 @@ Similar to a linked list, inserting and removing nodes in a binary tree can be a ??? pythontutor "Code Visualization" -
+
Full Screen >
!!! tip diff --git a/ja/docs/chapter_array_and_linkedlist/array.md b/ja/docs/chapter_array_and_linkedlist/array.md index 673d7b0a3..bf0c98f09 100644 --- a/ja/docs/chapter_array_and_linkedlist/array.md +++ b/ja/docs/chapter_array_and_linkedlist/array.md @@ -134,7 +134,7 @@ comments: true ??? pythontutor "実行の可視化" -
+
全画面で見る >
### 2.   要素へのアクセス @@ -320,7 +320,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 3.   要素の挿入 @@ -516,7 +516,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 4.   要素の削除 @@ -686,7 +686,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
全体として見ると、配列の挿入と削除には次の欠点があります。 @@ -933,7 +933,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 6.   要素の検索 @@ -1115,7 +1115,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 7.   配列の拡張 @@ -1345,7 +1345,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 4.1.2   配列の利点と限界 diff --git a/ja/docs/chapter_array_and_linkedlist/linked_list.md b/ja/docs/chapter_array_and_linkedlist/linked_list.md index f7b7d119d..26aa0e9bd 100644 --- a/ja/docs/chapter_array_and_linkedlist/linked_list.md +++ b/ja/docs/chapter_array_and_linkedlist/linked_list.md @@ -421,7 +421,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
配列全体は 1 つの変数であり、たとえば配列 `nums` には `nums[0]` や `nums[1]` などの要素が含まれます。一方、連結リストは複数の独立したノードオブジェクトで構成されます。**通常、先頭ノードを連結リストの代名詞として扱います**。たとえば上記のコードの連結リストは `n0` と表せます。 @@ -582,7 +582,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 3.   ノードの削除 @@ -785,7 +785,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 4.   ノードへのアクセス @@ -989,7 +989,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 5.   ノードの探索 @@ -1215,7 +1215,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 4.2.2   配列 vs. 連結リスト diff --git a/ja/docs/chapter_array_and_linkedlist/list.md b/ja/docs/chapter_array_and_linkedlist/list.md index 788ebaade..2c0f42554 100644 --- a/ja/docs/chapter_array_and_linkedlist/list.md +++ b/ja/docs/chapter_array_and_linkedlist/list.md @@ -153,7 +153,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
### 2.   要素へのアクセス @@ -285,7 +285,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
### 3.   要素の挿入と削除 @@ -540,7 +540,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
### 4.   リストの走査 @@ -742,7 +742,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
### 5.   リストの連結 @@ -854,7 +854,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
### 6.   リストをソート @@ -953,7 +953,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
## 4.3.2   リストの実装 @@ -2297,5 +2297,5 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
diff --git a/ja/docs/chapter_backtracking/backtracking_algorithm.md b/ja/docs/chapter_backtracking/backtracking_algorithm.md index 20b4740d2..edf2ef40d 100644 --- a/ja/docs/chapter_backtracking/backtracking_algorithm.md +++ b/ja/docs/chapter_backtracking/backtracking_algorithm.md @@ -234,7 +234,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
![前順走査でノードを探索する](backtracking_algorithm.assets/preorder_find_nodes.png){ class="animation-figure" } @@ -545,7 +545,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
各「試行」で現在のノードを `path` に追加して経路を記録し、「戻る」前にはそのノードを `path` から取り除き、**今回の試行前の状態を復元する**必要があります。 @@ -899,7 +899,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
「枝刈り」は非常にイメージしやすい名称です。次の図のように、探索中に**制約条件を満たさない探索分岐を切り落とす**ことで、多くの無意味な試行を避け、探索効率を高められます。 @@ -1926,7 +1926,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
問題の条件より、値が $7$ のノードを見つけた後も探索を続ける必要があります。**そのため、解を記録した後の `return` 文は削除しなければなりません**。次の図は、`return` 文を残す場合と削除する場合の探索過程を比較したものです。 diff --git a/ja/docs/chapter_backtracking/n_queens_problem.md b/ja/docs/chapter_backtracking/n_queens_problem.md index e55327cc1..b26617bb7 100644 --- a/ja/docs/chapter_backtracking/n_queens_problem.md +++ b/ja/docs/chapter_backtracking/n_queens_problem.md @@ -746,7 +746,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
行ごとに $n$ 回配置し、列制約を考慮すると、1 行目から最終行までの選択肢はそれぞれ $n$、$n-1$、$\dots$、$2$、$1$ 個となるため、時間計算量は $O(n!)$ です。解を記録する際には、行列 `state` をコピーして `res` に追加する必要があり、このコピー操作には $O(n^2)$ 時間を要します。したがって、**全体の時間計算量は $O(n! \cdot n^2)$** です。実際には、対角線制約による枝刈りも探索空間を大きく縮小できるため、探索効率はしばしば上記の時間計算量より良くなります。 diff --git a/ja/docs/chapter_backtracking/permutations_problem.md b/ja/docs/chapter_backtracking/permutations_problem.md index 8c3ea74a3..05b611b84 100644 --- a/ja/docs/chapter_backtracking/permutations_problem.md +++ b/ja/docs/chapter_backtracking/permutations_problem.md @@ -540,7 +540,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 13.2.2   等しい要素を考慮する場合 @@ -1087,7 +1087,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
要素どうしがすべて互いに異なると仮定すると、$n$ 個の要素には全部で $n!$ 通りの順列(階乗)があります。結果を記録する際には、長さ $n$ のリストをコピーする必要があり、これに $O(n)$ 時間を要します。**したがって時間計算量は $O(n!n)$** です。 diff --git a/ja/docs/chapter_backtracking/subset_sum_problem.md b/ja/docs/chapter_backtracking/subset_sum_problem.md index 9747f7052..832d7f373 100644 --- a/ja/docs/chapter_backtracking/subset_sum_problem.md +++ b/ja/docs/chapter_backtracking/subset_sum_problem.md @@ -524,7 +524,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
上のコードに配列 $[3, 4, 5]$ と目標値 $9$ を入力すると、出力は $[3, 3, 3], [4, 5], [5, 4]$ となります。**和が $9$ となる部分集合はすべて見つかっていますが、重複する部分集合 $[4, 5]$ と $[5, 4]$ が含まれています**。 @@ -1085,7 +1085,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は、配列 $[3, 4, 5]$ と目標値 $9$ を上のコードに入力したときの、全体のバックトラッキング過程を示しています。 @@ -1696,7 +1696,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は、配列 $[4, 4, 5]$ と目標値 $9$ に対するバックトラッキング過程を示しており、全部で 4 種類の枝刈り操作が含まれています。図とコードコメントを対応させながら、探索全体の流れと、各枝刈り操作がどのように機能するかを理解してください。 diff --git a/ja/docs/chapter_computational_complexity/iteration_and_recursion.md b/ja/docs/chapter_computational_complexity/iteration_and_recursion.md index 4296b14a6..8b80793d1 100644 --- a/ja/docs/chapter_computational_complexity/iteration_and_recursion.md +++ b/ja/docs/chapter_computational_complexity/iteration_and_recursion.md @@ -200,7 +200,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は、この総和関数のフローチャートです。 @@ -430,7 +430,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
**`while` ループは `for` ループより自由度が高い**です。`while` ループでは、条件変数の初期化や更新手順を柔軟に設計できます。 @@ -689,7 +689,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
総じて、**`for` ループのコードはより簡潔で、`while` ループはより柔軟**です。どちらも反復構造を実現できますが、どちらを使うかは問題ごとの要件に応じて決めるべきです。 @@ -924,7 +924,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は、このネストしたループのフローチャートです。 @@ -1147,7 +1147,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は、この関数の再帰過程を示しています。 @@ -1360,7 +1360,7 @@ $1 + 2 + \dots + n$ の計算を例にすると、結果変数 `res` を関数 ??? pythontutor "コードの可視化" -
+
全画面で見る >
末尾再帰の実行過程を次の図に示します。通常の再帰と末尾再帰を比べると、加算処理が実行されるタイミングが異なります。 @@ -1586,7 +1586,7 @@ $1 + 2 + \dots + n$ の計算を例にすると、結果変数 `res` を関数 ??? pythontutor "コードの可視化" -
+
全画面で見る >
上のコードを見ると、関数内で 2 回の再帰呼び出しを行っています。**これは 1 回の呼び出しから 2 つの呼び出し分岐が生じることを意味します**。次の図のように、この再帰呼び出しを繰り返していくと、最終的に深さ $n$ の再帰木(recursion tree)が生成されます。 @@ -1931,7 +1931,7 @@ $1 + 2 + \dots + n$ の計算を例にすると、結果変数 `res` を関数 ??? pythontutor "コードの可視化" -
+
全画面で見る >
上のコードを見ると、再帰を反復へ変換すると、コードはより複雑になります。反復と再帰は多くの場合に相互変換できますが、常にそうする価値があるとは限りません。理由は次の 2 点です。 diff --git a/ja/docs/chapter_computational_complexity/space_complexity.md b/ja/docs/chapter_computational_complexity/space_complexity.md index eecfc13b9..53786914c 100644 --- a/ja/docs/chapter_computational_complexity/space_complexity.md +++ b/ja/docs/chapter_computational_complexity/space_complexity.md @@ -1179,7 +1179,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 2.   線形階 $O(n)$ {data-toc-label="2.   線形階"} @@ -1457,7 +1457,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
以下の図に示すように、この関数の再帰の深さは $n$ であり、同時に $n$ 個の未返却 `linear_recur()` 関数が存在するため、$O(n)$ のスタックフレーム空間を使用します。 @@ -1629,7 +1629,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
![再帰関数が生み出す線形階の空間計算量](space_complexity.assets/space_complexity_recursive_linear.png){ class="animation-figure" } @@ -1862,7 +1862,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
以下の図に示すように、この関数の再帰の深さは $n$ であり、各再帰関数の中で長さがそれぞれ $n$、$n-1$、$\dots$、$2$、$1$ の配列を初期化しています。平均長は $n / 2$ なので、全体では $O(n^2)$ の空間を占有します。 @@ -2049,7 +2049,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
![再帰関数が生み出す平方階の空間計算量](space_complexity.assets/space_complexity_recursive_quadratic.png){ class="animation-figure" } @@ -2258,7 +2258,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
![満二分木が生み出す指数階の空間計算量](space_complexity.assets/space_complexity_exponential.png){ class="animation-figure" } diff --git a/ja/docs/chapter_computational_complexity/time_complexity.md b/ja/docs/chapter_computational_complexity/time_complexity.md index 56463f24c..256413861 100644 --- a/ja/docs/chapter_computational_complexity/time_complexity.md +++ b/ja/docs/chapter_computational_complexity/time_complexity.md @@ -1194,7 +1194,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 2.   線形階 $O(n)$ {data-toc-label="2.   線形階"} @@ -1360,7 +1360,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
配列走査や連結リスト走査などの操作の時間計算量はいずれも $O(n)$ であり、ここでの $n$ は配列または連結リストの長さです: @@ -1556,7 +1556,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
注意すべきなのは、**入力データサイズ $n$ は入力データの型に応じて具体的に定める必要がある**ということです。例えば 1 つ目の例では変数 $n$ が入力データサイズであり、2 つ目の例では配列長 $n$ がデータサイズです。 @@ -1774,7 +1774,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
以下の図は、定数階・線形階・平方階の 3 種類の時間計算量を比較したものです。 @@ -2097,7 +2097,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 4.   指数階 $O(2^n)$ {data-toc-label="4.   指数階"} @@ -2381,7 +2381,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
![指数階の時間計算量](time_complexity.assets/time_complexity_exponential.png){ class="animation-figure" } @@ -2582,7 +2582,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
指数階の増加は非常に速く、全探索法(ブルートフォース、バックトラッキングなど)によく見られます。データ規模が大きい問題では、指数階は受け入れられず、通常は動的計画法や貪欲法などを使って解く必要があります。 @@ -2834,7 +2834,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
![対数階の時間計算量](time_complexity.assets/time_complexity_logarithmic.png){ class="animation-figure" } @@ -3052,7 +3052,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
対数階は分割統治に基づくアルゴリズムによく現れ、「1 つを複数に分ける」「複雑なものを単純化する」という考え方を体現しています。増加は緩やかで、定数階に次いで理想的な時間計算量です。 @@ -3266,7 +3266,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
下図は線形対数階がどのように生じるかを示しています。二分木の各層の操作総数はすべて $n$ であり、木全体は $\log_2 n + 1$ 層あるため、時間計算量は $O(n \log n)$ です。 @@ -3502,7 +3502,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
![階乗階の時間計算量](time_complexity.assets/time_complexity_factorial.png){ class="animation-figure" } @@ -3896,7 +3896,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
実際には、最良時間計算量を使うことはあまりありません。通常それが実現する確率はごく低く、誤解を招く可能性があるからです。**一方で最悪時間計算量はより実用的で、効率の安全側の目安を与えてくれる**ため、安心してアルゴリズムを使えます。 diff --git a/ja/docs/chapter_data_structure/basic_data_types.md b/ja/docs/chapter_data_structure/basic_data_types.md index 436640e81..772e8d257 100644 --- a/ja/docs/chapter_data_structure/basic_data_types.md +++ b/ja/docs/chapter_data_structure/basic_data_types.md @@ -180,5 +180,5 @@ comments: true ??? pythontutor "実行の可視化" -
+
全画面で見る >
diff --git a/ja/docs/chapter_divide_and_conquer/binary_search_recur.md b/ja/docs/chapter_divide_and_conquer/binary_search_recur.md index 491d30c3d..98a48acae 100644 --- a/ja/docs/chapter_divide_and_conquer/binary_search_recur.md +++ b/ja/docs/chapter_divide_and_conquer/binary_search_recur.md @@ -452,5 +452,5 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
diff --git a/ja/docs/chapter_divide_and_conquer/build_binary_tree_problem.md b/ja/docs/chapter_divide_and_conquer/build_binary_tree_problem.md index e8030e586..ed58a1017 100644 --- a/ja/docs/chapter_divide_and_conquer/build_binary_tree_problem.md +++ b/ja/docs/chapter_divide_and_conquer/build_binary_tree_problem.md @@ -514,7 +514,7 @@ $m$ の検索効率を高めるために、ハッシュテーブル `hmap` を ??? pythontutor "コードの可視化" -
+
全画面で見る >
下図は二分木を構築する再帰過程を示しています。各ノードは下向きに「再帰していく」過程で生成され、各辺(参照)は上向きに「戻る」過程で張られます。 diff --git a/ja/docs/chapter_divide_and_conquer/hanota_problem.md b/ja/docs/chapter_divide_and_conquer/hanota_problem.md index 3e3976694..5c8b372d2 100644 --- a/ja/docs/chapter_divide_and_conquer/hanota_problem.md +++ b/ja/docs/chapter_divide_and_conquer/hanota_problem.md @@ -544,7 +544,7 @@ $f(1)$ と $f(2)$ の解が既知なので、分割統治の観点から、**`A` ??? pythontutor "コードの可視化" -
+
全画面で見る >
以下の図に示すように、ハノイの塔の問題は高さ $n$ の再帰木を形成し、各ノードは 1 つの部分問題、すなわち 1 つ起動された `dfs()` 関数に対応します。**したがって時間計算量は $O(2^n)$ 、空間計算量は $O(n)$** です。 diff --git a/ja/docs/chapter_dynamic_programming/dp_problem_features.md b/ja/docs/chapter_dynamic_programming/dp_problem_features.md index 781ffaa40..0a610294c 100644 --- a/ja/docs/chapter_dynamic_programming/dp_problem_features.md +++ b/ja/docs/chapter_dynamic_programming/dp_problem_features.md @@ -320,7 +320,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
下図は上記コードの動的計画法の過程を示しています。 @@ -583,7 +583,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 14.2.2   無後効性 @@ -944,7 +944,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
上の例では、追加で考慮すべきなのは一つ前の状態だけであるため、状態定義を拡張することで問題を再び無後効性に適合させることができます。しかし、問題によっては非常に強い「後効性」があります。 diff --git a/ja/docs/chapter_dynamic_programming/dp_solution_pipeline.md b/ja/docs/chapter_dynamic_programming/dp_solution_pipeline.md index 85574b6ae..0664fc75c 100644 --- a/ja/docs/chapter_dynamic_programming/dp_solution_pipeline.md +++ b/ja/docs/chapter_dynamic_programming/dp_solution_pipeline.md @@ -385,7 +385,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は、$dp[2, 1]$ を根ノードとする再帰木を示しています。この中にはいくつかの重複部分問題が含まれており、その数はグリッド `grid` のサイズが大きくなるにつれて急激に増加します。 @@ -744,7 +744,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図に示すように、メモ化を導入すると、すべての部分問題の解は 1 回だけ計算すればよくなります。したがって時間計算量は状態総数、すなわちグリッドサイズの $O(nm)$ に依存します。 @@ -1119,7 +1119,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は最小経路和の状態遷移の過程を示しています。グリッド全体を走査するため、**時間計算量は $O(nm)$** です。 @@ -1507,5 +1507,5 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
diff --git a/ja/docs/chapter_dynamic_programming/edit_distance_problem.md b/ja/docs/chapter_dynamic_programming/edit_distance_problem.md index 24112489e..a9ec94687 100644 --- a/ja/docs/chapter_dynamic_programming/edit_distance_problem.md +++ b/ja/docs/chapter_dynamic_programming/edit_distance_problem.md @@ -479,7 +479,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
下図に示すように、編集距離問題の状態遷移の過程はナップサック問題と非常によく似ており、どちらも2次元グリッドを埋めていく過程とみなせます。 @@ -968,5 +968,5 @@ $dp[i,j]$ は上の $dp[i-1, j]$、左の $dp[i, j-1]$、左上の $dp[i-1, j-1] ??? pythontutor "コードの可視化" -
+
全画面で見る >
diff --git a/ja/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md b/ja/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md index 72fbf6d02..d1588f47b 100644 --- a/ja/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md +++ b/ja/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -422,7 +422,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 14.1.1   方法 1:総当たり探索 @@ -697,7 +697,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は総当たり探索によって形成される再帰木を示しています。問題 $dp[n]$ に対して、その再帰木の深さは $n$、時間計算量は $O(2^n)$ です。指数オーダーは爆発的に増加するため、比較的大きな $n$ を入力すると長時間待たされることになります。 @@ -1064,7 +1064,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図を見ると、**メモ化を行うことで、すべての重複部分問題は 1 回だけ計算すればよくなり、時間計算量は $O(n)$ まで改善されます**。これは大きな飛躍です。 @@ -1340,7 +1340,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は、以上のコードの実行過程をシミュレートしたものです。 @@ -1577,7 +1577,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
上のコードを見ると、配列 `dp` が占めていた領域を省けるため、空間計算量は $O(n)$ から $O(1)$ へと下がります。 diff --git a/ja/docs/chapter_dynamic_programming/knapsack_problem.md b/ja/docs/chapter_dynamic_programming/knapsack_problem.md index f2b31016d..49cf7c88a 100644 --- a/ja/docs/chapter_dynamic_programming/knapsack_problem.md +++ b/ja/docs/chapter_dynamic_programming/knapsack_problem.md @@ -340,7 +340,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
以下の図のように、各品物ごとに「選ばない」「選ぶ」の2つの探索分岐が生じるため、時間計算量は $O(2^n)$ です。 @@ -708,7 +708,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は、メモ化探索で剪定された探索分岐を示しています。 @@ -1059,7 +1059,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
以下の図のように、時間計算量と空間計算量はいずれも配列 `dp` のサイズによって決まり、$O(n \times cap)$ です。 @@ -1441,5 +1441,5 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
diff --git a/ja/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md b/ja/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md index 52ae74f56..794886b09 100644 --- a/ja/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/ja/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -373,7 +373,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 3.   空間最適化 @@ -747,7 +747,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 14.5.2   硬貨交換問題 @@ -1194,7 +1194,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図は硬貨交換の動的計画法の過程を示しており、完全ナップサック問題と非常によく似ています。 @@ -1609,7 +1609,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 14.5.3   硬貨交換問題 II @@ -2011,7 +2011,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 3.   空間最適化 @@ -2347,5 +2347,5 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
diff --git a/ja/docs/chapter_graph/graph_operations.md b/ja/docs/chapter_graph/graph_operations.md index 64abc24f2..efc4191cf 100644 --- a/ja/docs/chapter_graph/graph_operations.md +++ b/ja/docs/chapter_graph/graph_operations.md @@ -1208,7 +1208,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 9.2.2   隣接リストに基づく実装 @@ -2366,7 +2366,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 9.2.3   効率の比較 diff --git a/ja/docs/chapter_graph/graph_traversal.md b/ja/docs/chapter_graph/graph_traversal.md index 149d6710d..200e35b06 100644 --- a/ja/docs/chapter_graph/graph_traversal.md +++ b/ja/docs/chapter_graph/graph_traversal.md @@ -477,7 +477,7 @@ BFS は通常キューを用いて実装され、コードは以下のとおり ??? pythontutor "コードの可視化" -
+
全画面で見る >
コードはやや抽象的なので、以下の図と照らし合わせて理解を深めることを勧めます。 @@ -937,7 +937,7 @@ BFS は通常キューを用いて実装され、コードは以下のとおり ??? pythontutor "コードの可視化" -
+
全画面で見る >
深さ優先走査のアルゴリズムの流れは以下の図のとおりです。 diff --git a/ja/docs/chapter_greedy/fractional_knapsack_problem.md b/ja/docs/chapter_greedy/fractional_knapsack_problem.md index fe53f2a4a..ac6083a88 100644 --- a/ja/docs/chapter_greedy/fractional_knapsack_problem.md +++ b/ja/docs/chapter_greedy/fractional_knapsack_problem.md @@ -533,7 +533,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
組み込みのソートアルゴリズムの時間計算量は通常 $O(n \log n)$、空間計算量は通常 $O(\log n)$ または $O(n)$ であり、具体的な値はプログラミング言語の実装に依存する。 diff --git a/ja/docs/chapter_greedy/greedy_algorithm.md b/ja/docs/chapter_greedy/greedy_algorithm.md index 22ff9eaa1..7260ed3b1 100644 --- a/ja/docs/chapter_greedy/greedy_algorithm.md +++ b/ja/docs/chapter_greedy/greedy_algorithm.md @@ -332,7 +332,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
思わずこう言いたくなるかもしれません。So clean!貪欲法はわずか十行ほどのコードでコイン両替問題を解いてしまいます。 diff --git a/ja/docs/chapter_greedy/max_capacity_problem.md b/ja/docs/chapter_greedy/max_capacity_problem.md index a3f067991..3dddb7bcf 100644 --- a/ja/docs/chapter_greedy/max_capacity_problem.md +++ b/ja/docs/chapter_greedy/max_capacity_problem.md @@ -423,7 +423,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 3.   正しさの証明 diff --git a/ja/docs/chapter_greedy/max_product_cutting_problem.md b/ja/docs/chapter_greedy/max_product_cutting_problem.md index 76ef45ae6..763557148 100644 --- a/ja/docs/chapter_greedy/max_product_cutting_problem.md +++ b/ja/docs/chapter_greedy/max_product_cutting_problem.md @@ -388,7 +388,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
![最大積分割の計算方法](max_product_cutting_problem.assets/max_product_cutting_greedy_calculation.png){ class="animation-figure" } diff --git a/ja/docs/chapter_hashing/hash_algorithm.md b/ja/docs/chapter_hashing/hash_algorithm.md index d528bc54e..8b7684866 100644 --- a/ja/docs/chapter_hashing/hash_algorithm.md +++ b/ja/docs/chapter_hashing/hash_algorithm.md @@ -644,7 +644,7 @@ index = hash(key) % capacity ??? pythontutor "コードの可視化" -
+
全画面で見る >
見て分かるように、各ハッシュアルゴリズムの最後のステップでは、大きな素数 $1000000007$ で剰余を取り、ハッシュ値が適切な範囲に収まるようにしています。ここで考えてみる価値があるのは、なぜ素数での剰余を強調するのか、あるいは合成数で剰余を取ることにどんな欠点があるのか、という点です。これは興味深い問題です。 @@ -1002,7 +1002,7 @@ $$ ??? pythontutor "可視化実行" -
+
全画面で見る >
多くのプログラミング言語では、**不変オブジェクトだけがハッシュテーブルの `key` として使えます**。仮にリスト(動的配列)を `key` とすると、その内容が変化したときにハッシュ値も変わってしまうため、もとの `value` をハッシュテーブルから検索できなくなります。 diff --git a/ja/docs/chapter_hashing/hash_collision.md b/ja/docs/chapter_hashing/hash_collision.md index dbca6767c..6682d2e6c 100644 --- a/ja/docs/chapter_hashing/hash_collision.md +++ b/ja/docs/chapter_hashing/hash_collision.md @@ -1519,7 +1519,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
注意すべきなのは、連結リストが長い場合、検索効率 $O(n)$ は非常に低いことです。**このとき、連結リストを「AVL 木」または「赤黒木」に変換することで**、検索操作の時間計算量を $O(\log n)$ に最適化できます。 diff --git a/ja/docs/chapter_hashing/hash_map.md b/ja/docs/chapter_hashing/hash_map.md index 763a65ba2..2da72cce1 100644 --- a/ja/docs/chapter_hashing/hash_map.md +++ b/ja/docs/chapter_hashing/hash_map.md @@ -325,7 +325,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
ハッシュテーブルには、キーと値のペア、キー、値を走査する 3 つの一般的な方法があります。コード例は以下のとおりです: @@ -547,7 +547,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
## 6.1.2   ハッシュテーブルの簡単な実装 @@ -1755,7 +1755,7 @@ index = hash(key) % capacity ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 6.1.3   ハッシュ衝突と拡張 diff --git a/ja/docs/chapter_heap/build_heap.md b/ja/docs/chapter_heap/build_heap.md index e0a14037d..094072524 100644 --- a/ja/docs/chapter_heap/build_heap.md +++ b/ja/docs/chapter_heap/build_heap.md @@ -324,7 +324,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 8.2.3   計算量の分析 diff --git a/ja/docs/chapter_heap/heap.md b/ja/docs/chapter_heap/heap.md index 63deb2e23..4a82e4b9a 100644 --- a/ja/docs/chapter_heap/heap.md +++ b/ja/docs/chapter_heap/heap.md @@ -420,7 +420,7 @@ comments: true ??? pythontutor "実行を可視化" -
+
全画面で見る >
## 8.1.2   ヒープの実装 @@ -808,7 +808,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 3.   要素をヒープに追加 @@ -1213,7 +1213,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 4.   ヒープ頂点の要素を取り出す @@ -1770,7 +1770,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 8.1.3   ヒープの代表的な応用 diff --git a/ja/docs/chapter_heap/top_k.md b/ja/docs/chapter_heap/top_k.md index 7c4e8d75d..2042cffcd 100644 --- a/ja/docs/chapter_heap/top_k.md +++ b/ja/docs/chapter_heap/top_k.md @@ -463,7 +463,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
合計で $n$ 回のヒープ挿入と取り出しを行い、ヒープの最大長は $k$ であるため、時間計算量は $O(n \log k)$ です。この方法は非常に効率が高く、$k$ が小さいときは時間計算量が $O(n)$ に近づき、$k$ が大きいときでも $O(n \log n)$ を超えることはありません。 diff --git a/ja/docs/chapter_searching/binary_search.md b/ja/docs/chapter_searching/binary_search.md index 7cc2f7aa2..2bd41b5ce 100644 --- a/ja/docs/chapter_searching/binary_search.md +++ b/ja/docs/chapter_searching/binary_search.md @@ -364,7 +364,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
**時間計算量は $O(\log n)$** :二分探索のループでは各ラウンドで区間が半分になるため、ループ回数は $\log_2 n$ です。 @@ -688,7 +688,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
次の図に示すように、2 種類の区間表現では、二分探索アルゴリズムの初期化、ループ条件、区間の縮小操作がそれぞれ異なります。 diff --git a/ja/docs/chapter_searching/binary_search_edge.md b/ja/docs/chapter_searching/binary_search_edge.md index 36daed647..81c8484b7 100644 --- a/ja/docs/chapter_searching/binary_search_edge.md +++ b/ja/docs/chapter_searching/binary_search_edge.md @@ -226,7 +226,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 10.3.2   右端境界を探す @@ -481,7 +481,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 2.   要素探索に変換する diff --git a/ja/docs/chapter_searching/binary_search_insertion.md b/ja/docs/chapter_searching/binary_search_insertion.md index 06ead69f2..197d2f7be 100644 --- a/ja/docs/chapter_searching/binary_search_insertion.md +++ b/ja/docs/chapter_searching/binary_search_insertion.md @@ -317,7 +317,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 10.2.2   重複要素がある場合 @@ -662,7 +662,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
!!! tip diff --git a/ja/docs/chapter_searching/replace_linear_by_hashing.md b/ja/docs/chapter_searching/replace_linear_by_hashing.md index 19ef07bd6..fa3423ef4 100644 --- a/ja/docs/chapter_searching/replace_linear_by_hashing.md +++ b/ja/docs/chapter_searching/replace_linear_by_hashing.md @@ -243,7 +243,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
この方法の時間計算量は $O(n^2)$ ,空間計算量は $O(1)$ であり,大規模データでは非常に時間がかかります。 @@ -538,7 +538,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
この方法ではハッシュ探索によって時間計算量を $O(n^2)$ から $O(n)$ に下げ,実行効率を大幅に向上させます。 diff --git a/ja/docs/chapter_sorting/bubble_sort.md b/ja/docs/chapter_sorting/bubble_sort.md index b8ca4b6e1..4d612619c 100644 --- a/ja/docs/chapter_sorting/bubble_sort.md +++ b/ja/docs/chapter_sorting/bubble_sort.md @@ -292,7 +292,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.3.2   効率の最適化 @@ -612,7 +612,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.3.3   アルゴリズムの特徴 diff --git a/ja/docs/chapter_sorting/bucket_sort.md b/ja/docs/chapter_sorting/bucket_sort.md index bb874ba3a..279fa585c 100644 --- a/ja/docs/chapter_sorting/bucket_sort.md +++ b/ja/docs/chapter_sorting/bucket_sort.md @@ -439,7 +439,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.8.2   アルゴリズムの特性 diff --git a/ja/docs/chapter_sorting/counting_sort.md b/ja/docs/chapter_sorting/counting_sort.md index 365fb86d6..846c330f9 100644 --- a/ja/docs/chapter_sorting/counting_sort.md +++ b/ja/docs/chapter_sorting/counting_sort.md @@ -363,7 +363,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
!!! note "計数ソートとバケットソートの関係" @@ -879,7 +879,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.9.3   アルゴリズムの特性 diff --git a/ja/docs/chapter_sorting/heap_sort.md b/ja/docs/chapter_sorting/heap_sort.md index 8e14f7f9c..ceadc0e8b 100644 --- a/ja/docs/chapter_sorting/heap_sort.md +++ b/ja/docs/chapter_sorting/heap_sort.md @@ -614,7 +614,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.7.2   アルゴリズムの特性 diff --git a/ja/docs/chapter_sorting/insertion_sort.md b/ja/docs/chapter_sorting/insertion_sort.md index 95bcd59bf..d49cc0180 100644 --- a/ja/docs/chapter_sorting/insertion_sort.md +++ b/ja/docs/chapter_sorting/insertion_sort.md @@ -272,7 +272,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.4.2   アルゴリズムの特徴 diff --git a/ja/docs/chapter_sorting/merge_sort.md b/ja/docs/chapter_sorting/merge_sort.md index 804c45d96..a5d095153 100644 --- a/ja/docs/chapter_sorting/merge_sort.md +++ b/ja/docs/chapter_sorting/merge_sort.md @@ -681,7 +681,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.6.2   アルゴリズムの特性 diff --git a/ja/docs/chapter_sorting/quick_sort.md b/ja/docs/chapter_sorting/quick_sort.md index b0a9b2ffc..0ae1adffc 100644 --- a/ja/docs/chapter_sorting/quick_sort.md +++ b/ja/docs/chapter_sorting/quick_sort.md @@ -368,7 +368,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.5.1   アルゴリズムの流れ @@ -595,7 +595,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.5.2   アルゴリズムの特性 @@ -1095,7 +1095,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.5.5   再帰の深さの最適化 @@ -1404,5 +1404,5 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
diff --git a/ja/docs/chapter_sorting/radix_sort.md b/ja/docs/chapter_sorting/radix_sort.md index dfe66b3d1..8825cc731 100644 --- a/ja/docs/chapter_sorting/radix_sort.md +++ b/ja/docs/chapter_sorting/radix_sort.md @@ -724,7 +724,7 @@ $$ ??? pythontutor "コードの可視化" -
+
全画面で見る >
!!! question "なぜ最下位桁からソートするのですか?" diff --git a/ja/docs/chapter_sorting/selection_sort.md b/ja/docs/chapter_sorting/selection_sort.md index 51e2070e4..eb2ece3b6 100644 --- a/ja/docs/chapter_sorting/selection_sort.md +++ b/ja/docs/chapter_sorting/selection_sort.md @@ -326,7 +326,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 11.2.1   アルゴリズムの特徴 diff --git a/ja/docs/chapter_stack_and_queue/deque.md b/ja/docs/chapter_stack_and_queue/deque.md index d0cf7fdc8..dd03e2f9e 100644 --- a/ja/docs/chapter_stack_and_queue/deque.md +++ b/ja/docs/chapter_stack_and_queue/deque.md @@ -395,7 +395,7 @@ comments: true ??? pythontutor "実行の可視化" -
+
全画面で見る >
## 5.3.2   両端キューの実装 * diff --git a/ja/docs/chapter_stack_and_queue/queue.md b/ja/docs/chapter_stack_and_queue/queue.md index 20883f1a0..ddf11c135 100644 --- a/ja/docs/chapter_stack_and_queue/queue.md +++ b/ja/docs/chapter_stack_and_queue/queue.md @@ -368,7 +368,7 @@ comments: true ??? pythontutor "可視化実行" -
+
全画面で見る >
## 5.2.2   キューの実装 @@ -1313,7 +1313,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 2.   配列に基づく実装 @@ -2288,7 +2288,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
上記の実装によるキューにも制約があり、長さを可変にできません。しかし、この問題の解決は難しくなく、配列を動的配列に置き換えれば容量拡張の仕組みを導入できます。興味があれば自分で実装してみてください。 diff --git a/ja/docs/chapter_stack_and_queue/stack.md b/ja/docs/chapter_stack_and_queue/stack.md index 06ff5bcb5..7f09a5073 100644 --- a/ja/docs/chapter_stack_and_queue/stack.md +++ b/ja/docs/chapter_stack_and_queue/stack.md @@ -361,7 +361,7 @@ comments: true ??? pythontutor "実行の可視化" -
+
全画面で見る >
## 5.1.2   スタックの実装 @@ -1162,7 +1162,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 2.   配列による実装 @@ -1804,7 +1804,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 5.1.3   2つの実装の比較 diff --git a/ja/docs/chapter_tree/array_representation_of_tree.md b/ja/docs/chapter_tree/array_representation_of_tree.md index 0282a156c..48e618e90 100644 --- a/ja/docs/chapter_tree/array_representation_of_tree.md +++ b/ja/docs/chapter_tree/array_representation_of_tree.md @@ -1330,7 +1330,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
## 7.3.3   利点と制約 diff --git a/ja/docs/chapter_tree/binary_search_tree.md b/ja/docs/chapter_tree/binary_search_tree.md index 8a8a7542b..98cede59d 100644 --- a/ja/docs/chapter_tree/binary_search_tree.md +++ b/ja/docs/chapter_tree/binary_search_tree.md @@ -340,7 +340,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 2.   ノードの挿入 @@ -804,7 +804,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
ノード探索と同様に、ノード挿入には $O(\log n)$ 時間を要します。 @@ -1590,7 +1590,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 4.   中順走査は昇順 diff --git a/ja/docs/chapter_tree/binary_tree.md b/ja/docs/chapter_tree/binary_tree.md index 0dcf74953..04831a39e 100644 --- a/ja/docs/chapter_tree/binary_tree.md +++ b/ja/docs/chapter_tree/binary_tree.md @@ -459,7 +459,7 @@ comments: true ??? pythontutor "実行の可視化" -
+
全画面で見る >
### 2.   ノードの挿入と削除 @@ -628,7 +628,7 @@ comments: true ??? pythontutor "実行の可視化" -
+
全画面で見る >
!!! tip diff --git a/ja/docs/chapter_tree/binary_tree_traversal.md b/ja/docs/chapter_tree/binary_tree_traversal.md index 291c08af2..96bb24fb6 100644 --- a/ja/docs/chapter_tree/binary_tree_traversal.md +++ b/ja/docs/chapter_tree/binary_tree_traversal.md @@ -336,7 +336,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
### 2.   計算量 @@ -821,7 +821,7 @@ comments: true ??? pythontutor "コードの可視化" -
+
全画面で見る >
!!! tip diff --git a/ko/docs/chapter_appendix/contribution.md b/ko/docs/chapter_appendix/contribution.md new file mode 100644 index 000000000..d6e81af64 --- /dev/null +++ b/ko/docs/chapter_appendix/contribution.md @@ -0,0 +1,53 @@ +--- +comments: true +--- + +# 16.2   함께 만드는 책 + +저자의 역량이 부족하여 책에 누락이나 오류가 있을 수 있다는 점을 양해 부탁드린다. 오탈자, 끊어진 링크, 빠진 내용, 모호한 표현, 불분명한 설명, 부자연스러운 글의 구성 등을 발견했다면 수정에 힘을 보태 주기 바란다. 독자에게 더 좋은 학습 자료를 제공하는 데 큰 도움이 된다. + +모든 [집필 참여자](https://github.com/krahets/hello-algo/graphs/contributors)의 GitHub ID는 저장소와 웹 버전의 첫 페이지에 표시된다. 이는 오픈 소스 커뮤니티에 아낌없이 기여한 분들에게 감사를 전하기 위한 것이다. + +!!! success "오픈 소스의 매력" + + 종이책은 인쇄와 다음 인쇄 사이의 간격이 길어 내용을 갱신하기가 매우 불편하다. + + 반면 이 오픈 소스 도서에서는 내용을 갱신하는 데 걸리는 시간이 며칠, 때로는 몇 시간으로 줄어든다. + +### 1.   간단한 내용 수정 + +아래 그림(16-3)과 같이 각 페이지의 오른쪽 위에는 ‘편집 아이콘’이 있다. 다음 순서로 글이나 코드를 수정할 수 있다. + +1. ‘편집 아이콘’을 누른다. 저장소를 포크해야 한다는 안내가 나오면 동의한다. +2. Markdown 소스 파일을 수정하고 내용이 올바른지 확인한다. 서식은 가능한 한 일관되게 유지한다. +3. 페이지 아래쪽에 수정 내용을 설명하고 ‘Propose file change’ 버튼을 누른다. 이동한 페이지에서 ‘Create pull request’ 버튼을 누르면 풀 리퀘스트를 만들 수 있다. + +![페이지 편집 버튼](contribution.assets/edit_markdown.png){ class="animation-figure" } + +

그림 16-3   페이지 편집 버튼

+ +그림은 직접 수정할 수 없으므로 [Issue](https://github.com/krahets/hello-algo/issues)를 만들거나 댓글로 문제를 설명해 주기 바란다. 확인 후 최대한 빨리 그림을 다시 제작하여 교체하겠다. + +### 2.   내용 작성 + +코드를 다른 프로그래밍 언어로 옮기거나 글의 내용을 확장하는 등 이 오픈 소스 프로젝트에 참여하고 싶다면 다음 풀 리퀘스트 절차를 따르면 된다. + +1. GitHub에 로그인한 뒤 이 책의 [코드 저장소](https://github.com/krahets/hello-algo)를 개인 계정으로 포크한다. +2. 포크한 저장소의 웹페이지로 이동하여 `git clone` 명령으로 저장소를 로컬에 복제한다. +3. 로컬에서 내용을 작성하고 충분히 테스트하여 코드가 올바른지 검증한다. +4. 로컬 변경 사항을 커밋한 뒤 원격 저장소로 푸시한다. +5. 저장소 웹페이지를 새로 고침하고 ‘Create pull request’ 버튼을 눌러 풀 리퀘스트를 만든다. + +### 3.   Docker 배포 + +`hello-algo` 루트 디렉터리에서 다음 Docker 명령을 실행하면 `http://localhost:8000/ko/`에서 한국어판을 열 수 있다. + +```shell +docker-compose up -d +``` + +다음 명령으로 배포를 제거할 수 있다. + +```shell +docker-compose down +``` diff --git a/ko/docs/chapter_appendix/index.md b/ko/docs/chapter_appendix/index.md new file mode 100644 index 000000000..a47b4d49c --- /dev/null +++ b/ko/docs/chapter_appendix/index.md @@ -0,0 +1,14 @@ +--- +comments: true +icon: material/help-circle-outline +--- + +# 제 16 장   부록 + +![부록](../assets/covers/chapter_appendix.jpg){ class="cover-image" } + +## 이 장의 내용 + +- [16.1   프로그래밍 환경 설치](installation.md) +- [16.2   함께 만드는 책](contribution.md) +- [16.3   용어집](terminology.md) diff --git a/ko/docs/chapter_appendix/installation.md b/ko/docs/chapter_appendix/installation.md new file mode 100644 index 000000000..fb3d0b849 --- /dev/null +++ b/ko/docs/chapter_appendix/installation.md @@ -0,0 +1,76 @@ +--- +comments: true +--- + +# 16.1   프로그래밍 환경 설치 + +## 16.1.1   IDE 설치 + +로컬 통합 개발 환경(IDE)으로는 가벼운 오픈 소스 도구인 VS Code를 권장한다. [VS Code 공식 웹사이트](https://code.visualstudio.com/)에서 운영체제에 맞는 버전을 내려받아 설치한다. + +![공식 웹사이트에서 VS Code 내려받기](installation.assets/vscode_installation.png){ class="animation-figure" } + +

그림 16-1   공식 웹사이트에서 VS Code 내려받기

+ +VS Code는 풍부한 확장 생태계를 갖추고 있어 대부분의 프로그래밍 언어를 실행하고 디버깅할 수 있다. Python을 예로 들면, ‘Python Extension Pack’을 설치한 뒤 Python 코드를 디버깅할 수 있다. 설치 과정은 아래 그림(16-2)과 같다. + +![VS Code 확장 설치](installation.assets/vscode_extension_installation.png){ class="animation-figure" } + +

그림 16-2   VS Code 확장 설치

+ +## 16.1.2   언어 실행 환경 설치 + +### 1.   Python 환경 + +1. [Miniconda3](https://docs.conda.io/en/latest/miniconda.html)를 내려받아 설치한다. Python 3.10 이상이 필요하다. +2. VS Code 확장 마켓플레이스에서 `python`을 검색하여 Python Extension Pack을 설치한다. +3. (선택 사항) 명령줄에 `pip install black`을 입력하여 코드 포매터를 설치한다. + +### 2.   C/C++ 환경 + +1. Windows에서는 [MinGW](https://sourceforge.net/projects/mingw-w64/files/)를 설치해야 한다([설정 안내](https://blog.csdn.net/qq_33698226/article/details/129031241)). macOS에는 Clang이 기본으로 포함되어 있어 별도로 설치할 필요가 없다. +2. VS Code 확장 마켓플레이스에서 `c++`를 검색하여 C/C++ Extension Pack을 설치한다. +3. (선택 사항) Settings 페이지에서 코드 서식 옵션인 `Clang_format_fallback Style`을 검색하고 `{ BasedOnStyle: Microsoft, BreakBeforeBraces: Attach }`로 설정한다. + +### 3.   Java 환경 + +1. [OpenJDK](https://jdk.java.net/18/)를 내려받아 설치한다(JDK 9보다 높은 버전이어야 한다). +2. VS Code 확장 마켓플레이스에서 `java`를 검색하여 Extension Pack for Java를 설치한다. + +### 4.   C# 환경 + +1. [.NET 8.0](https://dotnet.microsoft.com/ko-kr/download)을 내려받아 설치한다. +2. VS Code 확장 마켓플레이스에서 `C# Dev Kit`을 검색하여 C# Dev Kit을 설치한다([설정 안내](https://code.visualstudio.com/docs/csharp/get-started)). +3. Visual Studio를 사용해도 된다([설치 안내](https://learn.microsoft.com/ko-kr/visualstudio/install/install-visual-studio?view=vs-2022)). + +### 5.   Go 환경 + +1. [go](https://go.dev/dl/)를 내려받아 설치한다. +2. VS Code 확장 마켓플레이스에서 `go`를 검색하여 Go를 설치한다. +3. `Ctrl + Shift + P`를 눌러 명령 팔레트를 열고 go를 입력한다. `Go: Install/Update Tools`를 선택한 뒤 모든 항목을 체크하여 설치한다. + +### 6.   Swift 환경 + +1. [Swift](https://www.swift.org/download/)를 내려받아 설치한다. +2. VS Code 확장 마켓플레이스에서 `swift`를 검색하여 [Swift for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=sswg.swift-lang)를 설치한다. + +### 7.   JavaScript 환경 + +1. [Node.js](https://nodejs.org/ko/)를 내려받아 설치한다. +2. (선택 사항) VS Code 확장 마켓플레이스에서 `Prettier`를 검색하여 코드 포매터를 설치한다. + +### 8.   TypeScript 환경 + +1. JavaScript 환경과 같은 순서로 설치한다. +2. [TypeScript Execute (tsx)](https://github.com/privatenumber/tsx?tab=readme-ov-file#global-installation)를 설치한다. +3. VS Code 확장 마켓플레이스에서 `typescript`를 검색하여 [Pretty TypeScript Errors](https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors)를 설치한다. + +### 9.   Dart 환경 + +1. [Dart](https://dart.dev/get-dart)를 내려받아 설치한다. +2. VS Code 확장 마켓플레이스에서 `dart`를 검색하여 [Dart](https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code)를 설치한다. + +### 10.   Rust 환경 + +1. [Rust](https://www.rust-lang.org/tools/install)를 내려받아 설치한다. +2. VS Code 확장 마켓플레이스에서 `rust`를 검색하여 [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)를 설치한다. diff --git a/ko/docs/chapter_appendix/terminology.md b/ko/docs/chapter_appendix/terminology.md new file mode 100644 index 000000000..04c1ddc6f --- /dev/null +++ b/ko/docs/chapter_appendix/terminology.md @@ -0,0 +1,144 @@ +--- +comments: true +--- + +# 16.3   용어집 + +아래 표(16-1)에는 이 책에 나오는 주요 용어를 정리했다. 영어 문헌을 읽을 때 도움이 되므로 각 용어의 영어 명칭을 기억해 두기를 권장한다. + +

표 16-1   자료 구조와 알고리즘의 주요 용어

+ +
+ +| English | 한국어 | +| ------------------------------ | -------------- | +| algorithm | 알고리즘 | +| data structure | 자료 구조 | +| code | 코드 | +| file | 파일 | +| function | 함수 | +| method | 메서드 | +| variable | 변수 | +| asymptotic complexity analysis | 점근적 복잡도 분석 | +| time complexity | 시간 복잡도 | +| space complexity | 공간 복잡도 | +| loop | 반복문 | +| iteration | 반복 | +| recursion | 재귀 | +| tail recursion | 꼬리 재귀 | +| recursion tree | 재귀 트리 | +| big-$O$ notation | 빅 $O$ 표기법 | +| asymptotic upper bound | 점근적 상한 | +| sign-magnitude | 부호와 크기 표현 | +| 1’s complement | 1의 보수 | +| 2’s complement | 2의 보수 | +| array | 배열 | +| index | 인덱스 | +| linked list | 연결 리스트 | +| linked list node, list node | 연결 리스트 노드 | +| head node | 머리 노드 | +| tail node | 꼬리 노드 | +| list | 리스트 | +| dynamic array | 동적 배열 | +| hard disk | 하드 디스크 | +| random-access memory (RAM) | 메모리 | +| cache memory | 캐시 메모리 | +| cache miss | 캐시 미스 | +| cache hit rate | 캐시 적중률 | +| stack | 스택 | +| top of the stack | 스택의 맨 위 | +| bottom of the stack | 스택의 맨 아래 | +| queue | 큐 | +| double-ended queue | 덱 | +| front of the queue | 큐의 앞 | +| rear of the queue | 큐의 뒤 | +| hash table | 해시 테이블 | +| hash set | 해시 집합 | +| bucket | 버킷 | +| hash function | 해시 함수 | +| hash collision | 해시 충돌 | +| load factor | 적재율 | +| separate chaining | 체이닝 | +| open addressing | 개방 주소법 | +| linear probing | 선형 탐사 | +| lazy deletion | 지연 삭제 | +| binary tree | 이진 트리 | +| tree node | 트리 노드 | +| left-child node | 왼쪽 자식 노드 | +| right-child node | 오른쪽 자식 노드 | +| parent node | 부모 노드 | +| left subtree | 왼쪽 서브트리 | +| right subtree | 오른쪽 서브트리 | +| root node | 루트 노드 | +| leaf node | 리프 노드 | +| edge | 간선 | +| level | 레벨 | +| degree | 차수 | +| height | 높이 | +| depth | 깊이 | +| perfect binary tree | 포화 이진 트리 | +| complete binary tree | 완전 이진 트리 | +| full binary tree | 정 이진 트리 | +| balanced binary tree | 균형 이진 트리 | +| binary search tree | 이진 탐색 트리 | +| AVL tree | AVL 트리 | +| red-black tree | 레드-블랙 트리 | +| level-order traversal | 레벨 순서 순회 | +| breadth-first traversal | 너비 우선 순회 | +| depth-first traversal | 깊이 우선 순회 | +| pre-order traversal | 전위 순회 | +| in-order traversal | 중위 순회 | +| post-order traversal | 후위 순회 | +| balanced binary search tree | 균형 이진 탐색 트리 | +| balance factor | 균형 인수 | +| heap | 힙 | +| max heap | 최대 힙 | +| min heap | 최소 힙 | +| priority queue | 우선순위 큐 | +| heapify | 힙 재정렬 | +| top-$k$ problem | Top-$k$ 문제 | +| graph | 그래프 | +| vertex | 정점 | +| undirected graph | 무방향 그래프 | +| directed graph | 방향 그래프 | +| connected graph | 연결 그래프 | +| disconnected graph | 비연결 그래프 | +| weighted graph | 가중 그래프 | +| adjacency | 인접 | +| path | 경로 | +| in-degree | 진입 차수 | +| out-degree | 진출 차수 | +| adjacency matrix | 인접 행렬 | +| adjacency list | 인접 리스트 | +| breadth-first search | 너비 우선 탐색 | +| depth-first search | 깊이 우선 탐색 | +| binary search | 이진 탐색 | +| searching algorithm | 탐색 알고리즘 | +| sorting algorithm | 정렬 알고리즘 | +| selection sort | 선택 정렬 | +| bubble sort | 버블 정렬 | +| insertion sort | 삽입 정렬 | +| quick sort | 퀵 정렬 | +| merge sort | 병합 정렬 | +| heap sort | 힙 정렬 | +| bucket sort | 버킷 정렬 | +| counting sort | 계수 정렬 | +| radix sort | 기수 정렬 | +| divide and conquer | 분할 정복 | +| hanota problem | 하노이의 탑 문제 | +| backtracking algorithm | 백트래킹 알고리즘 | +| constraint | 제약 조건 | +| solution | 해 | +| state | 상태 | +| pruning | 가지치기 | +| permutations problem | 모든 순열 구하기 | +| subset-sum problem | 부분 집합의 합 문제 | +| $n$-queens problem | $n$-퀸 문제 | +| dynamic programming | 동적 계획법 | +| initial state | 초기 상태 | +| state-transition equation | 상태 전이 식 | +| knapsack problem | 배낭 문제 | +| edit distance problem | 편집 거리 문제 | +| greedy algorithm | 탐욕 알고리즘 | + +
diff --git a/ko/docs/chapter_array_and_linkedlist/array.md b/ko/docs/chapter_array_and_linkedlist/array.md new file mode 100644 index 000000000..0f972a924 --- /dev/null +++ b/ko/docs/chapter_array_and_linkedlist/array.md @@ -0,0 +1,1373 @@ +--- +comments: true +--- + +# 4.1   배열 + +배열(array)은 같은 자료형의 원소를 연속된 메모리 공간에 저장하는 선형 자료 구조다. 배열에서 원소가 있는 위치를 해당 원소의 인덱스(index)라고 한다. 아래 그림(4-1)은 배열의 주요 개념과 저장 방식을 보여 준다. + +![배열의 정의와 저장 방식](array.assets/array_definition.png){ class="animation-figure" } + +

그림 4-1   배열의 정의와 저장 방식

+ +## 4.1.1   배열의 기본 연산 + +### 1.   배열 초기화 + +필요에 따라 초깃값을 지정하지 않거나 지정하는 두 가지 초기화 방식을 사용할 수 있다. 초깃값을 지정하지 않으면 대부분의 프로그래밍 언어는 배열 원소를 $0$으로 초기화한다. + +=== "Python" + + ```python title="array.py" + # 배열 초기화 + arr: list[int] = [0] * 5 # [ 0, 0, 0, 0, 0 ] + nums: list[int] = [1, 3, 2, 5, 4] + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 배열 초기화 */ + // 스택에 저장 + int arr[5] = {}; + int nums[5] = { 1, 3, 2, 5, 4 }; + // 힙에 저장(공간을 직접 해제해야 함) + int* arr1 = new int[5] {}; + int* nums1 = new int[5] { 1, 3, 2, 5, 4 }; + ``` + +=== "Java" + + ```java title="array.java" + /* 배열 초기화 */ + int[] arr = new int[5]; // { 0, 0, 0, 0, 0 } + int[] nums = { 1, 3, 2, 5, 4 }; + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 배열 초기화 */ + int[] arr = new int[5]; // [ 0, 0, 0, 0, 0 ] + int[] nums = [1, 3, 2, 5, 4]; + ``` + +=== "Go" + + ```go title="array.go" + /* 배열 초기화 */ + var arr [5]int + // Go에서 길이를 지정하면([5]int) 배열이고, 지정하지 않으면([]int) 슬라이스다 + // Go 배열의 길이는 컴파일 시 결정되도록 설계되었으므로 상수로만 길이를 지정할 수 있다 + // 용량 확장 메서드 extend()를 쉽게 구현하기 위해 아래에서는 슬라이스(Slice)를 배열(Array)로 간주한다 + nums := []int{1, 3, 2, 5, 4} + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 배열 초기화 */ + let arr = Array(repeating: 0, count: 5) // [0, 0, 0, 0, 0] + let nums = [1, 3, 2, 5, 4] + ``` + +=== "JS" + + ```javascript title="array.js" + /* 배열 초기화 */ + var arr = new Array(5).fill(0); + var nums = [1, 3, 2, 5, 4]; + ``` + +=== "TS" + + ```typescript title="array.ts" + /* 배열 초기화 */ + let arr: number[] = new Array(5).fill(0); + let nums: number[] = [1, 3, 2, 5, 4]; + ``` + +=== "Dart" + + ```dart title="array.dart" + /* 배열 초기화 */ + List arr = List.filled(5, 0); // [0, 0, 0, 0, 0] + List nums = [1, 3, 2, 5, 4]; + ``` + +=== "Rust" + + ```rust title="array.rs" + /* 배열 초기화 */ + let arr: [i32; 5] = [0; 5]; // [0, 0, 0, 0, 0] + let slice: &[i32] = &[0; 5]; + // Rust에서 길이를 지정하면([i32; 5]) 배열이고, 지정하지 않으면(&[i32]) 슬라이스다 + // Rust 배열의 길이는 컴파일 시 결정되도록 설계되었으므로 상수로만 길이를 지정할 수 있다 + // Vector는 Rust에서 일반적으로 동적 배열로 사용하는 자료형이다 + // 용량 확장 메서드 extend()를 쉽게 구현하기 위해 아래에서는 vector를 배열(array)로 간주한다 + let nums: Vec = vec![1, 3, 2, 5, 4]; + ``` + +=== "C" + + ```c title="array.c" + /* 배열 초기화 */ + int arr[5] = { 0 }; // { 0, 0, 0, 0, 0 } + int nums[5] = { 1, 3, 2, 5, 4 }; + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + /* 배열 초기화 */ + var arr = IntArray(5) // { 0, 0, 0, 0, 0 } + var nums = intArrayOf(1, 3, 2, 5, 4) + ``` + +=== "Ruby" + + ```ruby title="array.rb" + # 배열 초기화 + arr = Array.new(5, 0) + nums = [1, 3, 2, 5, 4] + ``` + +??? pythontutor "코드 시각화" + +
+
전체 화면 >
+ +### 2.   원소 접근 + +배열 원소는 연속된 메모리 공간에 저장되므로 메모리 주소를 매우 쉽게 계산할 수 있다. 배열의 메모리 주소(첫 원소의 메모리 주소)와 원소의 인덱스가 주어지면, 아래 그림(4-2)의 식으로 해당 원소의 메모리 주소를 구하여 직접 접근할 수 있다. + +![배열 원소의 메모리 주소 계산](array.assets/array_memory_location_calculation.png){ class="animation-figure" } + +

그림 4-2   배열 원소의 메모리 주소 계산

+ +위 그림(4-2)에서 배열의 첫 원소 인덱스는 $0$이다. $1$부터 세는 편이 더 자연스러워 보이므로 직관에 어긋날 수 있다. 그러나 주소 계산식의 관점에서 **인덱스는 본질적으로 메모리 주소의 오프셋이다**. 첫 원소의 주소 오프셋은 $0$이므로 인덱스가 $0$인 것은 합리적이다. + +배열의 원소 접근은 매우 효율적이며, 어떤 원소든 $O(1)$ 시간에 임의 접근할 수 있다. + +=== "Python" + + ```python title="array.py" + def random_access(nums: list[int]) -> int: + """원소에 임의 접근""" + # 구간 [0, len(nums)-1]에서 숫자 하나를 무작위로 뽑는다 + random_index = random.randint(0, len(nums) - 1) + # 무작위 원소를 가져와 반환한다 + random_num = nums[random_index] + return random_num + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 원소에 임의 접근 */ + int randomAccess(int *nums, int size) { + // 구간 [0, size)에서 무작위로 수 하나 선택 + int randomIndex = rand() % size; + // 무작위 원소를 가져와 반환한다 + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "Java" + + ```java title="array.java" + /* 원소에 임의 접근 */ + int randomAccess(int[] nums) { + // 구간 [0, nums.length)에서 숫자 하나를 무작위로 뽑는다 + int randomIndex = ThreadLocalRandom.current().nextInt(0, nums.length); + // 무작위 원소를 가져와 반환한다 + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 원소에 임의 접근 */ + int RandomAccess(int[] nums) { + Random random = new(); + // 구간 [0, nums.Length)에서 무작위로 수 하나 선택 + int randomIndex = random.Next(nums.Length); + // 무작위 원소를 가져와 반환한다 + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "Go" + + ```go title="array.go" + /* 원소에 임의 접근 */ + func randomAccess(nums []int) (randomNum int) { + // 구간 [0, nums.length)에서 숫자 하나를 무작위로 뽑는다 + randomIndex := rand.Intn(len(nums)) + // 무작위 원소를 가져와 반환한다 + randomNum = nums[randomIndex] + return + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 원소에 임의 접근 */ + func randomAccess(nums: [Int]) -> Int { + // 구간 [0, nums.count)에서 숫자 하나를 무작위로 뽑는다 + let randomIndex = nums.indices.randomElement()! + // 무작위 원소를 가져와 반환한다 + let randomNum = nums[randomIndex] + return randomNum + } + ``` + +=== "JS" + + ```javascript title="array.js" + /* 원소에 임의 접근 */ + function randomAccess(nums) { + // 구간 [0, nums.length)에서 숫자 하나를 무작위로 뽑는다 + const random_index = Math.floor(Math.random() * nums.length); + // 무작위 원소를 가져와 반환한다 + const random_num = nums[random_index]; + return random_num; + } + ``` + +=== "TS" + + ```typescript title="array.ts" + /* 원소에 임의 접근 */ + function randomAccess(nums: number[]): number { + // 구간 [0, nums.length)에서 숫자 하나를 무작위로 뽑는다 + const random_index = Math.floor(Math.random() * nums.length); + // 무작위 원소를 가져와 반환한다 + const random_num = nums[random_index]; + return random_num; + } + ``` + +=== "Dart" + + ```dart title="array.dart" + /* 원소에 임의 접근 */ + int randomAccess(List nums) { + // 구간 [0, nums.length)에서 숫자 하나를 무작위로 뽑는다 + int randomIndex = Random().nextInt(nums.length); + // 무작위 원소를 가져와 반환한다 + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "Rust" + + ```rust title="array.rs" + /* 원소에 임의 접근 */ + fn random_access(nums: &[i32]) -> i32 { + // 구간 [0, nums.len())에서 숫자 하나를 무작위로 뽑는다 + let random_index = rand::thread_rng().gen_range(0..nums.len()); + // 무작위 원소를 가져와 반환한다 + let random_num = nums[random_index]; + random_num + } + ``` + +=== "C" + + ```c title="array.c" + /* 원소에 임의 접근 */ + int randomAccess(int *nums, int size) { + // 구간 [0, size)에서 무작위로 수 하나 선택 + int randomIndex = rand() % size; + // 무작위 원소를 가져와 반환한다 + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + /* 원소에 임의 접근 */ + fun randomAccess(nums: IntArray): Int { + // 구간 [0, nums.size)에서 숫자 하나를 무작위로 뽑는다 + val randomIndex = ThreadLocalRandom.current().nextInt(0, nums.size) + // 무작위 원소를 가져와 반환한다 + val randomNum = nums[randomIndex] + return randomNum + } + ``` + +=== "Ruby" + + ```ruby title="array.rb" + ### 원소에 임의 접근 ### + def random_access(nums) + # 구간 [0, nums.length)에서 숫자 하나를 무작위로 뽑는다 + random_index = Random.rand(0...nums.length) + + # 무작위 원소를 가져와 반환한다 + nums[random_index] + end + ``` + +??? pythontutor "코드 시각화" + +
+
전체 화면 >
+ +### 3.   원소 삽입 + +배열 원소는 메모리에서 ‘빈틈없이 붙어 있어’ 사이에 다른 데이터를 넣을 공간이 없다. 아래 그림(4-3)과 같이 배열 중간에 원소를 삽입하려면 해당 위치 이후의 모든 원소를 한 칸씩 뒤로 옮긴 다음, 그 인덱스에 새 원소를 대입해야 한다. + +![배열에 원소를 삽입하는 예](array.assets/array_insert_element.png){ class="animation-figure" } + +

그림 4-3   배열에 원소를 삽입하는 예

+ +배열의 길이는 고정되어 있으므로 원소를 하나 삽입하면 반드시 마지막 원소가 ‘사라진다’는 점에 주의하자. 이 문제의 해결책은 ‘리스트’ 절에서 다룬다. + +=== "Python" + + ```python title="array.py" + def insert(nums: list[int], num: int, index: int): + """배열의 인덱스 index에 원소 num 삽입""" + # 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for i in range(len(nums) - 1, index, -1): + nums[i] = nums[i - 1] + # index 위치의 원소에 num을 대입한다 + nums[index] = num + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 배열의 인덱스 index에 원소 num 삽입 */ + void insert(int *nums, int size, int num, int index) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for (int i = size - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num; + } + ``` + +=== "Java" + + ```java title="array.java" + /* 배열의 인덱스 index에 원소 num 삽입 */ + void insert(int[] nums, int num, int index) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for (int i = nums.length - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num; + } + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 배열의 인덱스 index에 원소 num 삽입 */ + void Insert(int[] nums, int num, int index) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for (int i = nums.Length - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num; + } + ``` + +=== "Go" + + ```go title="array.go" + /* 배열의 인덱스 index에 원소 num 삽입 */ + func insert(nums []int, num int, index int) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for i := len(nums) - 1; i > index; i-- { + nums[i] = nums[i-1] + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 배열의 인덱스 index에 원소 num 삽입 */ + func insert(nums: inout [Int], num: Int, index: Int) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for i in stride(from: nums.count - 1, to: index, by: -1) { + nums[i] = nums[i - 1] + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num + } + ``` + +=== "JS" + + ```javascript title="array.js" + /* 배열의 인덱스 index에 원소 num 삽입 */ + function insert(nums, num, index) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for (let i = nums.length - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num; + } + ``` + +=== "TS" + + ```typescript title="array.ts" + /* 배열의 인덱스 index에 원소 num 삽입 */ + function insert(nums: number[], num: number, index: number): void { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for (let i = nums.length - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num; + } + ``` + +=== "Dart" + + ```dart title="array.dart" + /* 배열의 인덱스 index에 원소 _num 삽입 */ + void insert(List nums, int _num, int index) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for (var i = nums.length - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // 인덱스 index의 원소에 _num을 대입한다 + nums[index] = _num; + } + ``` + +=== "Rust" + + ```rust title="array.rs" + /* 배열의 인덱스 index에 원소 num 삽입 */ + fn insert(nums: &mut [i32], num: i32, index: usize) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for i in (index + 1..nums.len()).rev() { + nums[i] = nums[i - 1]; + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num; + } + ``` + +=== "C" + + ```c title="array.c" + /* 배열의 인덱스 index에 원소 num 삽입 */ + void insert(int *nums, int size, int num, int index) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for (int i = size - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num; + } + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + /* 배열의 인덱스 index에 원소 num 삽입 */ + fun insert(nums: IntArray, num: Int, index: Int) { + // 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for (i in nums.size - 1 downTo index + 1) { + nums[i] = nums[i - 1] + } + // index 위치의 원소에 num을 대입한다 + nums[index] = num + } + ``` + +=== "Ruby" + + ```ruby title="array.rb" + ### 배열의 인덱스 index에 원소 num 삽입 ### + def insert(nums, num, index) + # 인덱스 index와 그 뒤에 있는 모든 원소를 한 칸씩 뒤로 이동한다 + for i in (nums.length - 1).downto(index + 1) + nums[i] = nums[i - 1] + end + + # index 위치의 원소에 num을 대입한다 + nums[index] = num + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 4.   원소 삭제 + +마찬가지로 아래 그림(4-4)과 같이 인덱스 $i$의 원소를 삭제하려면 인덱스 $i$ 뒤의 원소를 모두 한 칸씩 앞으로 옮겨야 한다. + +![배열에서 원소를 삭제하는 예](array.assets/array_remove_element.png){ class="animation-figure" } + +

그림 4-4   배열에서 원소를 삭제하는 예

+ +삭제가 끝난 뒤 원래의 마지막 원소는 ‘의미 없는’ 값이 되므로 따로 수정할 필요가 없다. + +=== "Python" + + ```python title="array.py" + def remove(nums: list[int], index: int): + """인덱스 index에 있는 원소 삭제""" + # 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for i in range(index, len(nums) - 1): + nums[i] = nums[i + 1] + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 인덱스 index에 있는 원소 삭제 */ + void remove(int *nums, int size, int index) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for (int i = index; i < size - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "Java" + + ```java title="array.java" + /* 인덱스 index에 있는 원소 삭제 */ + void remove(int[] nums, int index) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for (int i = index; i < nums.length - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 인덱스 index에 있는 원소 삭제 */ + void Remove(int[] nums, int index) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for (int i = index; i < nums.Length - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "Go" + + ```go title="array.go" + /* 인덱스 index에 있는 원소 삭제 */ + func remove(nums []int, index int) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for i := index; i < len(nums)-1; i++ { + nums[i] = nums[i+1] + } + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 인덱스 index에 있는 원소 삭제 */ + func remove(nums: inout [Int], index: Int) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for i in nums.indices.dropFirst(index).dropLast() { + nums[i] = nums[i + 1] + } + } + ``` + +=== "JS" + + ```javascript title="array.js" + /* 인덱스 index에 있는 원소 삭제 */ + function remove(nums, index) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for (let i = index; i < nums.length - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "TS" + + ```typescript title="array.ts" + /* 인덱스 index에 있는 원소 삭제 */ + function remove(nums: number[], index: number): void { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for (let i = index; i < nums.length - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "Dart" + + ```dart title="array.dart" + /* 인덱스 index에 있는 원소 삭제 */ + void remove(List nums, int index) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for (var i = index; i < nums.length - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "Rust" + + ```rust title="array.rs" + /* 인덱스 index에 있는 원소 삭제 */ + fn remove(nums: &mut [i32], index: usize) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for i in index..nums.len() - 1 { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "C" + + ```c title="array.c" + /* 인덱스 index에 있는 원소 삭제 */ + // 주의: stdio.h가 remove라는 이름을 사용한다 + void removeItem(int *nums, int size, int index) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for (int i = index; i < size - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + /* 인덱스 index에 있는 원소 삭제 */ + fun remove(nums: IntArray, index: Int) { + // 인덱스 index 뒤에 있는 모든 원소를 한 칸씩 앞으로 이동한다 + for (i in index.. + + +배열의 삽입과 삭제 연산에는 다음과 같은 단점이 있다. + +- **높은 시간 복잡도**: 삽입과 삭제의 평균 시간 복잡도는 모두 $O(n)$이며, $n$은 배열 길이다. +- **원소 손실**: 배열의 길이는 바꿀 수 없으므로 삽입 후 길이 범위를 벗어나는 원소가 사라진다. +- **메모리 낭비**: 긴 배열을 초기화하고 앞부분만 사용하면 삽입 시 사라지는 마지막 원소는 모두 ‘의미 없는’ 값이 된다. 하지만 이 방식은 메모리 공간 일부를 낭비한다. + +### 5.   배열 순회 + +대부분의 프로그래밍 언어에서는 인덱스로 배열을 순회할 수도 있고, 배열의 각 원소를 직접 얻으며 순회할 수도 있다. + +=== "Python" + + ```python title="array.py" + def traverse(nums: list[int]): + """배열 순회""" + count = 0 + # 인덱스를 통해 배열을 순회한다 + for i in range(len(nums)): + count += nums[i] + # 배열의 원소를 직접 순회한다 + for num in nums: + count += num + # 데이터의 인덱스와 원소를 함께 순회한다 + for i, num in enumerate(nums): + count += nums[i] + count += num + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 배열 순회 */ + void traverse(int *nums, int size) { + int count = 0; + // 인덱스를 통해 배열을 순회한다 + for (int i = 0; i < size; i++) { + count += nums[i]; + } + } + ``` + +=== "Java" + + ```java title="array.java" + /* 배열 순회 */ + void traverse(int[] nums) { + int count = 0; + // 인덱스를 통해 배열을 순회한다 + for (int i = 0; i < nums.length; i++) { + count += nums[i]; + } + // 배열의 원소를 직접 순회한다 + for (int num : nums) { + count += num; + } + } + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 배열 순회 */ + void Traverse(int[] nums) { + int count = 0; + // 인덱스를 통해 배열을 순회한다 + for (int i = 0; i < nums.Length; i++) { + count += nums[i]; + } + // 배열의 원소를 직접 순회한다 + foreach (int num in nums) { + count += num; + } + } + ``` + +=== "Go" + + ```go title="array.go" + /* 배열 순회 */ + func traverse(nums []int) { + count := 0 + // 인덱스를 통해 배열을 순회한다 + for i := 0; i < len(nums); i++ { + count += nums[i] + } + count = 0 + // 배열의 원소를 직접 순회한다 + for _, num := range nums { + count += num + } + // 데이터의 인덱스와 원소를 함께 순회한다 + for i, num := range nums { + count += nums[i] + count += num + } + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 배열 순회 */ + func traverse(nums: [Int]) { + var count = 0 + // 인덱스를 통해 배열을 순회한다 + for i in nums.indices { + count += nums[i] + } + // 배열의 원소를 직접 순회한다 + for num in nums { + count += num + } + // 데이터의 인덱스와 원소를 함께 순회한다 + for (i, num) in nums.enumerated() { + count += nums[i] + count += num + } + } + ``` + +=== "JS" + + ```javascript title="array.js" + /* 배열 순회 */ + function traverse(nums) { + let count = 0; + // 인덱스를 통해 배열을 순회한다 + for (let i = 0; i < nums.length; i++) { + count += nums[i]; + } + // 배열의 원소를 직접 순회한다 + for (const num of nums) { + count += num; + } + } + ``` + +=== "TS" + + ```typescript title="array.ts" + /* 배열 순회 */ + function traverse(nums: number[]): void { + let count = 0; + // 인덱스를 통해 배열을 순회한다 + for (let i = 0; i < nums.length; i++) { + count += nums[i]; + } + // 배열의 원소를 직접 순회한다 + for (const num of nums) { + count += num; + } + } + ``` + +=== "Dart" + + ```dart title="array.dart" + /* 배열 원소 순회 */ + void traverse(List nums) { + int count = 0; + // 인덱스를 통해 배열을 순회한다 + for (var i = 0; i < nums.length; i++) { + count += nums[i]; + } + // 배열의 원소를 직접 순회한다 + for (int _num in nums) { + count += _num; + } + // forEach 메서드로 배열을 순회한다 + nums.forEach((_num) { + count += _num; + }); + } + ``` + +=== "Rust" + + ```rust title="array.rs" + /* 배열 순회 */ + fn traverse(nums: &[i32]) { + let mut _count = 0; + // 인덱스를 통해 배열을 순회한다 + for i in 0..nums.len() { + _count += nums[i]; + } + // 배열의 원소를 직접 순회한다 + _count = 0; + for &num in nums { + _count += num; + } + } + ``` + +=== "C" + + ```c title="array.c" + /* 배열 순회 */ + void traverse(int *nums, int size) { + int count = 0; + // 인덱스를 통해 배열을 순회한다 + for (int i = 0; i < size; i++) { + count += nums[i]; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + /* 배열 순회 */ + fun traverse(nums: IntArray) { + var count = 0 + // 인덱스를 통해 배열을 순회한다 + for (i in nums.indices) { + count += nums[i] + } + // 배열의 원소를 직접 순회한다 + for (j in nums) { + count += j + } + } + ``` + +=== "Ruby" + + ```ruby title="array.rb" + ### 배열 순회 ### + def traverse(nums) + count = 0 + + # 인덱스를 통해 배열을 순회한다 + for i in 0...nums.length + count += nums[i] + end + + # 배열의 원소를 직접 순회한다 + for num in nums + count += num + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 6.   원소 탐색 + +배열에서 특정 원소를 찾으려면 배열을 순회하면서 매번 값이 일치하는지 확인하고, 일치하면 해당 인덱스를 출력한다. + +배열은 선형 자료 구조이므로 이 탐색 연산을 ‘선형 탐색’이라고 한다. + +=== "Python" + + ```python title="array.py" + def find(nums: list[int], target: int) -> int: + """배열에서 지정한 원소 탐색""" + for i in range(len(nums)): + if nums[i] == target: + return i + return -1 + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 배열에서 지정한 원소 탐색 */ + int find(int *nums, int size, int target) { + for (int i = 0; i < size; i++) { + if (nums[i] == target) + return i; + } + return -1; + } + ``` + +=== "Java" + + ```java title="array.java" + /* 배열에서 지정한 원소 탐색 */ + int find(int[] nums, int target) { + for (int i = 0; i < nums.length; i++) { + if (nums[i] == target) + return i; + } + return -1; + } + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 배열에서 지정한 원소 탐색 */ + int Find(int[] nums, int target) { + for (int i = 0; i < nums.Length; i++) { + if (nums[i] == target) + return i; + } + return -1; + } + ``` + +=== "Go" + + ```go title="array.go" + /* 배열에서 지정한 원소 탐색 */ + func find(nums []int, target int) (index int) { + index = -1 + for i := 0; i < len(nums); i++ { + if nums[i] == target { + index = i + break + } + } + return + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 배열에서 지정한 원소 탐색 */ + func find(nums: [Int], target: Int) -> Int { + for i in nums.indices { + if nums[i] == target { + return i + } + } + return -1 + } + ``` + +=== "JS" + + ```javascript title="array.js" + /* 배열에서 지정한 원소 탐색 */ + function find(nums, target) { + for (let i = 0; i < nums.length; i++) { + if (nums[i] === target) return i; + } + return -1; + } + ``` + +=== "TS" + + ```typescript title="array.ts" + /* 배열에서 지정한 원소 탐색 */ + function find(nums: number[], target: number): number { + for (let i = 0; i < nums.length; i++) { + if (nums[i] === target) { + return i; + } + } + return -1; + } + ``` + +=== "Dart" + + ```dart title="array.dart" + /* 배열에서 지정한 원소 탐색 */ + int find(List nums, int target) { + for (var i = 0; i < nums.length; i++) { + if (nums[i] == target) return i; + } + return -1; + } + ``` + +=== "Rust" + + ```rust title="array.rs" + /* 배열에서 지정한 원소 탐색 */ + fn find(nums: &[i32], target: i32) -> Option { + for i in 0..nums.len() { + if nums[i] == target { + return Some(i); + } + } + None + } + ``` + +=== "C" + + ```c title="array.c" + /* 배열에서 지정한 원소 탐색 */ + int find(int *nums, int size, int target) { + for (int i = 0; i < size; i++) { + if (nums[i] == target) + return i; + } + return -1; + } + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + /* 배열에서 지정한 원소 탐색 */ + fun find(nums: IntArray, target: Int): Int { + for (i in nums.indices) { + if (nums[i] == target) + return i + } + return -1 + } + ``` + +=== "Ruby" + + ```ruby title="array.rb" + ### 배열에서 지정한 원소 탐색 ### + def find(nums, target) + for i in 0...nums.length + return i if nums[i] == target + end + + -1 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 7.   배열 용량 확장 + +복잡한 시스템 환경에서는 배열 바로 뒤의 메모리 공간을 사용할 수 있다고 보장하기 어려우므로, 배열 용량을 안전하게 늘릴 수 없다. 따라서 대부분의 프로그래밍 언어에서 **배열 길이는 바꿀 수 없다**. + +배열 용량을 늘리려면 더 큰 배열을 새로 만들고 기존 원소를 차례로 복사해야 한다. 이는 $O(n)$ 연산으로, 배열이 매우 크면 시간이 많이 든다. 코드는 다음과 같다. + +=== "Python" + + ```python title="array.py" + def extend(nums: list[int], enlarge: int) -> list[int]: + """배열 길이 확장""" + # 길이를 확장한 배열을 초기화한다 + res = [0] * (len(nums) + enlarge) + # 원래 배열의 모든 원소를 새 배열에 복사한다 + for i in range(len(nums)): + res[i] = nums[i] + # 확장한 새 배열을 반환한다 + return res + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 배열 길이 확장 */ + int *extend(int *nums, int size, int enlarge) { + // 길이를 확장한 배열을 초기화한다 + int *res = new int[size + enlarge]; + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for (int i = 0; i < size; i++) { + res[i] = nums[i]; + } + // 메모리 해제 + delete[] nums; + // 확장한 새 배열을 반환한다 + return res; + } + ``` + +=== "Java" + + ```java title="array.java" + /* 배열 길이 확장 */ + int[] extend(int[] nums, int enlarge) { + // 길이를 확장한 배열을 초기화한다 + int[] res = new int[nums.length + enlarge]; + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for (int i = 0; i < nums.length; i++) { + res[i] = nums[i]; + } + // 확장한 새 배열을 반환한다 + return res; + } + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 배열 길이 확장 */ + int[] Extend(int[] nums, int enlarge) { + // 길이를 확장한 배열을 초기화한다 + int[] res = new int[nums.Length + enlarge]; + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for (int i = 0; i < nums.Length; i++) { + res[i] = nums[i]; + } + // 확장한 새 배열을 반환한다 + return res; + } + ``` + +=== "Go" + + ```go title="array.go" + /* 배열 길이 확장 */ + func extend(nums []int, enlarge int) []int { + // 길이를 확장한 배열을 초기화한다 + res := make([]int, len(nums)+enlarge) + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for i, num := range nums { + res[i] = num + } + // 확장한 새 배열을 반환한다 + return res + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 배열 길이 확장 */ + func extend(nums: [Int], enlarge: Int) -> [Int] { + // 길이를 확장한 배열을 초기화한다 + var res = Array(repeating: 0, count: nums.count + enlarge) + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for i in nums.indices { + res[i] = nums[i] + } + // 확장한 새 배열을 반환한다 + return res + } + ``` + +=== "JS" + + ```javascript title="array.js" + /* 배열 길이 확장 */ + // JavaScript의 Array는 동적 배열이므로 직접 확장할 수 있다 + // 학습의 편의를 위해 이 함수에서는 Array를 길이가 고정된 배열로 취급한다 + function extend(nums, enlarge) { + // 길이를 확장한 배열을 초기화한다 + const res = new Array(nums.length + enlarge).fill(0); + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for (let i = 0; i < nums.length; i++) { + res[i] = nums[i]; + } + // 확장한 새 배열을 반환한다 + return res; + } + ``` + +=== "TS" + + ```typescript title="array.ts" + /* 배열 길이 확장 */ + // TypeScript의 Array는 동적 배열이므로 직접 확장할 수 있다 + // 학습의 편의를 위해 이 함수에서는 Array를 길이가 고정된 배열로 취급한다 + function extend(nums: number[], enlarge: number): number[] { + // 길이를 확장한 배열을 초기화한다 + const res = new Array(nums.length + enlarge).fill(0); + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for (let i = 0; i < nums.length; i++) { + res[i] = nums[i]; + } + // 확장한 새 배열을 반환한다 + return res; + } + ``` + +=== "Dart" + + ```dart title="array.dart" + /* 배열 길이 확장 */ + List extend(List nums, int enlarge) { + // 길이를 확장한 배열을 초기화한다 + List res = List.filled(nums.length + enlarge, 0); + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for (var i = 0; i < nums.length; i++) { + res[i] = nums[i]; + } + // 확장한 새 배열을 반환한다 + return res; + } + ``` + +=== "Rust" + + ```rust title="array.rs" + /* 배열 길이 확장 */ + fn extend(nums: &[i32], enlarge: usize) -> Vec { + // 길이를 확장한 배열을 초기화한다 + let mut res: Vec = vec![0; nums.len() + enlarge]; + // 원래 배열의 모든 원소를 새 배열로 복사한다 + res[0..nums.len()].copy_from_slice(nums); + + // 확장한 새 배열을 반환한다 + res + } + ``` + +=== "C" + + ```c title="array.c" + /* 배열 길이 확장 */ + int *extend(int *nums, int size, int enlarge) { + // 길이를 확장한 배열을 초기화한다 + int *res = (int *)malloc(sizeof(int) * (size + enlarge)); + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for (int i = 0; i < size; i++) { + res[i] = nums[i]; + } + // 확장한 공간 초기화 + for (int i = size; i < size + enlarge; i++) { + res[i] = 0; + } + // 확장한 새 배열을 반환한다 + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + /* 배열 길이 확장 */ + fun extend(nums: IntArray, enlarge: Int): IntArray { + // 길이를 확장한 배열을 초기화한다 + val res = IntArray(nums.size + enlarge) + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for (i in nums.indices) { + res[i] = nums[i] + } + // 확장한 새 배열을 반환한다 + return res + } + ``` + +=== "Ruby" + + ```ruby title="array.rb" + ### 배열 길이 확장 ### + # Ruby의 Array는 동적 배열이므로 직접 확장할 수 있다 + # 학습의 편의를 위해 이 함수에서는 Array를 길이가 고정된 배열로 취급한다 + def extend(nums, enlarge) + # 길이를 확장한 배열을 초기화한다 + res = Array.new(nums.length + enlarge, 0) + + # 원래 배열의 모든 원소를 새 배열에 복사한다 + for i in 0...nums.length + res[i] = nums[i] + end + + # 확장한 새 배열을 반환한다 + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 4.1.2   배열의 장점과 한계 + +배열은 연속된 메모리 공간에 저장되고 원소의 자료형이 모두 같다. 이러한 풍부한 사전 정보를 활용하면 시스템은 자료 구조의 연산 효율을 최적화할 수 있다. + +- **높은 공간 효율**: 연속된 메모리 블록을 데이터에 할당하므로 추가적인 구조 비용이 없다. +- **임의 접근 지원**: 어떤 원소든 $O(1)$ 시간에 접근할 수 있다. +- **캐시 지역성**: 배열 원소에 접근할 때 컴퓨터는 그 원소뿐 아니라 주변 데이터도 캐시에 저장한다. 이를 통해 이후 연산의 실행 속도를 높일 수 있다. + +연속 공간 저장은 양날의 검으로, 다음과 같은 한계가 있다. + +- **낮은 삽입 및 삭제 효율**: 원소가 많으면 삽입이나 삭제 시 많은 원소를 이동해야 한다. +- **고정된 길이**: 초기화한 뒤에는 길이가 고정된다. 용량을 늘리려면 모든 데이터를 새 배열로 복사해야 하므로 비용이 크다. +- **공간 낭비**: 실제 필요한 크기보다 크게 할당하면 남는 공간이 낭비된다. + +## 4.1.3   배열의 대표적인 활용 + +배열은 기본적이면서도 흔히 쓰이는 자료 구조다. 다양한 알고리즘에 자주 사용되며 복잡한 자료 구조를 구현하는 데도 쓰인다. + +- **임의 접근**: 일부 표본을 무작위로 추출하려면 데이터를 배열에 저장하고 무작위 수열을 생성하여 인덱스에 따라 표본을 뽑을 수 있다. +- **정렬과 탐색**: 배열은 정렬 및 탐색 알고리즘에서 가장 많이 쓰이는 자료 구조다. 퀵 정렬, 병합 정렬, 이진 탐색 등은 주로 배열에서 수행된다. +- **조회 테이블**: 원소나 대응 관계를 빠르게 찾고 싶을 때 배열을 조회 테이블로 사용할 수 있다. 예를 들어 문자와 ASCII 코드의 대응을 구현하려면 문자의 ASCII 코드 값을 인덱스로 삼고, 해당 원소를 배열의 대응 위치에 저장하면 된다. +- **머신 러닝**: 신경망에서는 벡터, 행렬, 텐서 사이의 선형 대수 연산을 많이 사용하며, 이 데이터들은 모두 배열 형태로 구성된다. 배열은 신경망 프로그래밍에서 가장 많이 쓰는 자료 구조다. +- **자료 구조 구현**: 배열로 스택, 큐, 해시 테이블, 힙, 그래프 등을 구현할 수 있다. 예를 들어 그래프의 인접 행렬 표현은 실제로 이차원 배열이다. diff --git a/ko/docs/chapter_array_and_linkedlist/exercises.md b/ko/docs/chapter_array_and_linkedlist/exercises.md new file mode 100644 index 000000000..6388ea79e --- /dev/null +++ b/ko/docs/chapter_array_and_linkedlist/exercises.md @@ -0,0 +1,110 @@ +--- +comments: true +--- + +# 4.6   연습 문제 + +## 4.6.1   개념 확인 + +### 1.   배열과 연결 리스트에서 원소 찾기 + +배열과 단일 연결 리스트에 모두 `[A, B, C, D, E]`가 순서대로 저장되어 있다. 네 번째 원소 `D`를 읽으려고 한다. + + + +1. 배열에서는 어떤 인덱스를 바로 사용할 수 있는가? +2. 단일 연결 리스트에서는 머리 노드 `A`부터 `next`를 따라 어떤 노드를 차례대로 거쳐야 하는가? +3. 읽으려는 원소가 뒤쪽에 있을수록 두 구조에 필요한 단계 수는 어떻게 변하는가? 위치를 기준으로 반복해서 읽을 때는 어느 구조가 더 적합하며, 그 이유는 무엇인가? + +??? success "참고 답안" + + 1. 인덱스가 0부터 시작한다면 네 번째 원소의 인덱스는 3이므로, 배열에서는 `arr[3]`을 바로 읽을 수 있다. + + 2. 단일 연결 리스트는 맨 앞부터 시작해야 한다. 접근 경로는 `A → B → C → D`이며, `next`를 따라 3번 이동해야 한다. + + 3. 배열은 시작 주소와 인덱스로 원소의 위치를 바로 계산할 수 있으므로 위치를 기준으로 접근하는 시간 복잡도가 $O(1)$이다. + 단일 연결 리스트에서 $k$번째 노드에 접근하려면 머리 노드부터 `next`를 따라 $k-1$번 이동해야 하므로, + 최악의 경우 $O(n)$ 시간이 필요하다. + + 여기서는 위치를 기준으로 읽는 연산만 비교한 것이며, 연결 리스트가 모든 연산에서 더 느리다는 뜻은 아니다. + +### 2.   배열과 연결 리스트에 원소 삽입하기 + +배열과 단일 연결 리스트에 모두 `A, B, C, D`가 저장되어 있다. `B` 뒤에 `X`를 삽입하려고 한다. + +- 배열의 용량은 5이고 현재 상태는 `[A, B, C, D, _]`다. +- 연결 리스트는 `A → B → C → D`이며, 노드 `B`를 가리키는 참조를 이미 가지고 있다. + + + +1. 배열에서는 어떤 원소를 이동해야 하는가? 삽입 후 배열을 적어 보자. +2. 연결 리스트에서는 `X.next`와 `B.next`를 어떤 순서로 수정해야 하는가? 삽입 후 연결 리스트를 적어 보자. +3. 삽입 효율성을 비교할 때 ‘노드 B의 참조를 이미 가지고 있다’는 조건을 강조하는 이유는 무엇인가? + +??? success "참고 답안" + + 1. 배열에서는 먼저 `D`를 오른쪽으로 한 칸 옮기고, 다음으로 `C`를 오른쪽으로 한 칸 옮긴 뒤, `X`를 인덱스 2에 넣는다. + 결과는 `[A, B, X, C, D]`다. + + 2. `B.next`는 원래 `C`를 가리킨다. 먼저 `X.next = B.next`로 설정하여 `X`가 `C`를 가리키게 하고, + 다음으로 `B.next = X`로 설정한다. 결과는 `A → B → X → C → D`다. + 기존 연결을 저장하지 않은 채 `B.next`를 먼저 덮어쓰면 `C`를 찾지 못할 수 있다. + + 3. `B`의 위치를 알고 있다면 연결 리스트의 삽입은 연결 두 개만 수정하면 되므로 $O(1)$ 시간에 완료할 수 있다. + 맨 앞부터 `B`를 찾아야 한다면 탐색 자체에 $O(n)$ 시간이 걸릴 수 있다. + +### 3.   리스트의 용량은 어떻게 늘어나는가 + +배열로 구현한 리스트의 현재 내용은 `[A, B, C]`이고, 길이는 `size = 3`, 용량은 `capacity = 4`다. +용량이 부족해지면 새 배열의 용량을 기존의 2배로 늘리기로 한다. + + + +1. `D`를 추가한 뒤 리스트의 길이와 용량은 각각 얼마인가? 용량을 늘려야 하는가? +2. 이어서 `E`를 추가하면 용량은 얼마가 되는가? 기존 원소를 몇 개 복사해야 하는가? +3. 내부 배열의 길이는 바꿀 수 없는데, 리스트의 용량은 어떻게 늘어나는 것처럼 보이는가? + +??? success "참고 답안" + + 1. `D`는 마지막 빈자리에 넣을 수 있다. 내용은 `[A, B, C, D]`가 되고, + `size = 4`, `capacity = 4`이며 용량 확장은 필요 없다. + + 2. 이어서 `E`를 추가할 때는 빈자리가 없으므로 용량이 8인 새 배열을 만들어야 한다. + 기존 원소 `A, B, C, D` 네 개를 복사한 뒤 `E`를 넣는다. + 이때 `size = 5`, `capacity = 8`이다. + + 3. 원래 배열 자체의 길이가 늘어난 것은 아니다. 더 큰 새 배열을 만들고 기존 원소를 복사한 뒤, + 새 배열을 내부 저장 공간으로 사용하기 때문에 사용자에게는 용량이 늘어난 것으로 보인다. + +## 4.6.2   프로그래밍 연습 + +### 1.   배열로 표현한 큰 정수에 1 더하기 + +배열 `digits`에는 음이 아닌 정수의 각 자릿수가 왼쪽부터 저장되어 있다. 예를 들어 `[3, 0, 8]`은 308을 나타낸다. +숫자 0은 `[0]`으로 나타내며, 그 밖의 입력은 첫 번째 자릿수가 0이 아니다. + +십진수의 세로셈 덧셈 과정을 따라 이 정수에 1을 더한 뒤, 결과를 같은 배열 형식으로 반환하라. +`digits`를 직접 수정해도 된다. 맨 앞에 새로운 자리 올림이 생기면 더 긴 배열을 반환할 수 있다. + +??? tip "풀이 힌트" + + 1. 세로셈 덧셈처럼 배열의 마지막 자릿수부터 시작한다. + 2. 현재 자릿수가 9보다 작으면 1을 더한 뒤 바로 반환할 수 있다. + 3. 현재 자릿수가 9라면 0으로 바꾼다. 모든 자릿수가 9였다면 맨 앞에 1을 추가해야 한다. + +[LeetCode](https://leetcode.com/problems/plus-one/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } + +### 2.   단일 연결 리스트 뒤집기 + +단일 연결 리스트의 머리 노드 `head`가 주어진다. 각 노드에는 값과 다음 노드를 가리키는 `next`가 있다. + +반복 방식으로 모든 노드 사이의 연결 방향을 뒤집고, 뒤집힌 리스트의 머리 노드를 반환하라. +새로운 연결 리스트 노드를 만들어서는 안 된다. + +??? tip "풀이 힌트" + + 1. 먼저 종이에 연결된 노드 세 개와 prev, cur 두 포인터를 그려 보자. + 2. cur.next를 바꾸기 전에 nxt에 원래 다음 노드를 반드시 저장해야 한다. + 3. cur.next의 방향을 뒤집은 뒤 prev = cur, cur = nxt로 설정하고 원래 리스트의 다음 노드를 계속 처리한다. + +[LeetCode](https://leetcode.com/problems/reverse-linked-list/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_array_and_linkedlist/index.md b/ko/docs/chapter_array_and_linkedlist/index.md new file mode 100644 index 000000000..64d9854e7 --- /dev/null +++ b/ko/docs/chapter_array_and_linkedlist/index.md @@ -0,0 +1,23 @@ +--- +comments: true +icon: material/view-list-outline +--- + +# 제 4 장   배열과 연결 리스트 + +![배열과 연결 리스트](../assets/covers/chapter_array_and_linkedlist.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 자료 구조의 세계는 두꺼운 벽돌벽과 같다. + + 배열의 벽돌은 가지런히 빈틈없이 붙어 있다. 연결 리스트의 벽돌은 곳곳에 흩어져 있으며, 이들을 잇는 덩굴이 벽돌 틈 사이를 자유롭게 누빈다. + +## 이 장의 내용 + +- [4.1   배열](array.md) +- [4.2   연결 리스트](linked_list.md) +- [4.3   리스트](list.md) +- [4.4   메모리와 캐시 *](ram_and_cache.md) +- [4.5   요약](summary.md) +- [4.6   연습 문제](exercises.md) diff --git a/ko/docs/chapter_array_and_linkedlist/linked_list.md b/ko/docs/chapter_array_and_linkedlist/linked_list.md new file mode 100644 index 000000000..e4e3f73a8 --- /dev/null +++ b/ko/docs/chapter_array_and_linkedlist/linked_list.md @@ -0,0 +1,1467 @@ +--- +comments: true +--- + +# 4.2   연결 리스트 + +메모리 공간은 모든 프로그램이 함께 사용하는 자원이다. 복잡한 시스템 실행 환경에서는 빈 메모리 공간이 곳곳에 흩어져 있을 수 있다. 배열을 저장할 메모리는 연속적이어야 하므로, 배열이 매우 크면 그만큼 큰 연속 공간을 확보하지 못할 수도 있다. 이때 연결 리스트의 유연성이 장점으로 드러난다. + +연결 리스트(linked list)는 각 원소가 노드 객체이며, 노드들이 ‘참조’로 연결된 선형 자료 구조다. 참조는 다음 노드의 메모리 주소를 기록하므로, 이를 통해 현재 노드에서 다음 노드에 접근할 수 있다. + +이러한 설계 덕분에 각 노드는 메모리 곳곳에 분산되어 저장될 수 있으며, 메모리 주소가 연속적일 필요가 없다. + +![연결 리스트의 정의와 저장 방식](linked_list.assets/linkedlist_definition.png){ class="animation-figure" } + +

그림 4-5   연결 리스트의 정의와 저장 방식

+ +위 그림(4-5)에서 연결 리스트를 구성하는 단위는 노드(node) 객체다. 각 노드는 노드의 ‘값’과 다음 노드를 가리키는 ‘참조’라는 두 데이터를 포함한다. + +- 연결 리스트의 첫 노드를 ‘머리 노드’, 마지막 노드를 ‘꼬리 노드’라고 한다. +- 꼬리 노드는 ‘없음’을 가리키며, Java, C++, Python에서는 이를 각각 `null`, `nullptr`, `None`으로 표기한다. +- C, C++, Go, Rust처럼 포인터를 지원하는 언어에서는 위의 ‘참조’를 ‘포인터’로 바꾸어 이해하면 된다. + +다음 코드와 같이 연결 리스트 노드 `ListNode`는 값 외에도 참조(포인터)를 하나 더 저장해야 한다. 따라서 같은 양의 데이터를 저장할 때 **연결 리스트는 배열보다 메모리 공간을 더 많이 차지한다**. + +=== "Python" + + ```python title="" + class ListNode: + """연결 리스트 노드 클래스""" + def __init__(self, val: int): + self.val: int = val # 노드 값 + self.next: ListNode | None = None # 다음 노드를 가리키는 참조 + ``` + +=== "C++" + + ```cpp title="" + /* 연결 리스트 노드 구조체 */ + struct ListNode { + int val; // 노드 값 + ListNode *next; // 다음 노드를 가리키는 포인터 + ListNode(int x) : val(x), next(nullptr) {} // 생성자 + }; + ``` + +=== "Java" + + ```java title="" + /* 연결 리스트 노드 클래스 */ + class ListNode { + int val; // 노드 값 + ListNode next; // 다음 노드를 가리키는 참조 + ListNode(int x) { val = x; } // 생성자 + } + ``` + +=== "C#" + + ```csharp title="" + /* 연결 리스트 노드 클래스 */ + class ListNode(int x) { //생성자 + int val = x; // 노드 값 + ListNode? next; // 다음 노드를 가리키는 참조 + } + ``` + +=== "Go" + + ```go title="" + /* 연결 리스트 노드 구조체 */ + type ListNode struct { + Val int // 노드 값 + Next *ListNode // 다음 노드를 가리키는 포인터 + } + + // NewListNode 생성자: 새 연결 리스트 생성 + func NewListNode(val int) *ListNode { + return &ListNode{ + Val: val, + Next: nil, + } + } + ``` + +=== "Swift" + + ```swift title="" + /* 연결 리스트 노드 클래스 */ + class ListNode { + var val: Int // 노드 값 + var next: ListNode? // 다음 노드를 가리키는 참조 + + init(x: Int) { // 생성자 + val = x + } + } + ``` + +=== "JS" + + ```javascript title="" + /* 연결 리스트 노드 클래스 */ + class ListNode { + constructor(val, next) { + this.val = (val === undefined ? 0 : val); // 노드 값 + this.next = (next === undefined ? null : next); // 다음 노드를 가리키는 참조 + } + } + ``` + +=== "TS" + + ```typescript title="" + /* 연결 리스트 노드 클래스 */ + class ListNode { + val: number; + next: ListNode | null; + constructor(val?: number, next?: ListNode | null) { + this.val = val === undefined ? 0 : val; // 노드 값 + this.next = next === undefined ? null : next; // 다음 노드를 가리키는 참조 + } + } + ``` + +=== "Dart" + + ```dart title="" + /* 연결 리스트 노드 클래스 */ + class ListNode { + int val; // 노드 값 + ListNode? next; // 다음 노드를 가리키는 참조 + ListNode(this.val, [this.next]); // 생성자 + } + ``` + +=== "Rust" + + ```rust title="" + use std::rc::Rc; + use std::cell::RefCell; + /* 연결 리스트 노드 클래스 */ + #[derive(Debug)] + struct ListNode { + val: i32, // 노드 값 + next: Option>>, // 다음 노드를 가리키는 포인터 + } + ``` + +=== "C" + + ```c title="" + /* 연결 리스트 노드 구조체 */ + typedef struct ListNode { + int val; // 노드 값 + struct ListNode *next; // 다음 노드를 가리키는 포인터 + } ListNode; + + /* 생성자 */ + ListNode *newListNode(int val) { + ListNode *node; + node = (ListNode *) malloc(sizeof(ListNode)); + node->val = val; + node->next = NULL; + return node; + } + ``` + +=== "Kotlin" + + ```kotlin title="" + /* 연결 리스트 노드 클래스 */ + // 생성자 + class ListNode(x: Int) { + val _val: Int = x // 노드 값 + var next: ListNode? = null // 다음 노드를 가리키는 참조 + } + ``` + +=== "Ruby" + + ```ruby title="" + # 연결 리스트 노드 클래스 + class ListNode + attr_accessor :val # 노드 값 + attr_accessor :next # 다음 노드를 가리키는 참조 + + def initialize(val=0, next_node=nil) + @val = val + @next = next_node + end + end + ``` + +## 4.2.1   연결 리스트의 기본 연산 + +### 1.   연결 리스트 초기화 + +연결 리스트를 만드는 과정은 두 단계다. 먼저 각 노드 객체를 초기화한 다음, 노드 사이의 참조 관계를 설정한다. 초기화가 끝나면 머리 노드부터 시작하여 `next` 참조를 따라 모든 노드에 차례로 접근할 수 있다. + +=== "Python" + + ```python title="linked_list.py" + # 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 + # 각 노드 초기화 + n0 = ListNode(1) + n1 = ListNode(3) + n2 = ListNode(2) + n3 = ListNode(5) + n4 = ListNode(4) + # 노드 사이의 참조 설정 + n0.next = n1 + n1.next = n2 + n2.next = n3 + n3.next = n4 + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + ListNode* n0 = new ListNode(1); + ListNode* n1 = new ListNode(3); + ListNode* n2 = new ListNode(2); + ListNode* n3 = new ListNode(5); + ListNode* n4 = new ListNode(4); + // 노드 사이의 참조 설정 + n0->next = n1; + n1->next = n2; + n2->next = n3; + n3->next = n4; + ``` + +=== "Java" + + ```java title="linked_list.java" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + ListNode n0 = new ListNode(1); + ListNode n1 = new ListNode(3); + ListNode n2 = new ListNode(2); + ListNode n3 = new ListNode(5); + ListNode n4 = new ListNode(4); + // 노드 사이의 참조 설정 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + ListNode n0 = new(1); + ListNode n1 = new(3); + ListNode n2 = new(2); + ListNode n3 = new(5); + ListNode n4 = new(4); + // 노드 사이의 참조 설정 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "Go" + + ```go title="linked_list.go" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + n0 := NewListNode(1) + n1 := NewListNode(3) + n2 := NewListNode(2) + n3 := NewListNode(5) + n4 := NewListNode(4) + // 노드 사이의 참조 설정 + n0.Next = n1 + n1.Next = n2 + n2.Next = n3 + n3.Next = n4 + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + let n0 = ListNode(x: 1) + let n1 = ListNode(x: 3) + let n2 = ListNode(x: 2) + let n3 = ListNode(x: 5) + let n4 = ListNode(x: 4) + // 노드 사이의 참조 설정 + n0.next = n1 + n1.next = n2 + n2.next = n3 + n3.next = n4 + ``` + +=== "JS" + + ```javascript title="linked_list.js" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + const n0 = new ListNode(1); + const n1 = new ListNode(3); + const n2 = new ListNode(2); + const n3 = new ListNode(5); + const n4 = new ListNode(4); + // 노드 사이의 참조 설정 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "TS" + + ```typescript title="linked_list.ts" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + const n0 = new ListNode(1); + const n1 = new ListNode(3); + const n2 = new ListNode(2); + const n3 = new ListNode(5); + const n4 = new ListNode(4); + // 노드 사이의 참조 설정 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "Dart" + + ```dart title="linked_list.dart" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */\ + // 각 노드 초기화 + ListNode n0 = ListNode(1); + ListNode n1 = ListNode(3); + ListNode n2 = ListNode(2); + ListNode n3 = ListNode(5); + ListNode n4 = ListNode(4); + // 노드 사이의 참조 설정 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "Rust" + + ```rust title="linked_list.rs" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + let n0 = Rc::new(RefCell::new(ListNode { val: 1, next: None })); + let n1 = Rc::new(RefCell::new(ListNode { val: 3, next: None })); + let n2 = Rc::new(RefCell::new(ListNode { val: 2, next: None })); + let n3 = Rc::new(RefCell::new(ListNode { val: 5, next: None })); + let n4 = Rc::new(RefCell::new(ListNode { val: 4, next: None })); + + // 노드 사이의 참조 설정 + n0.borrow_mut().next = Some(n1.clone()); + n1.borrow_mut().next = Some(n2.clone()); + n2.borrow_mut().next = Some(n3.clone()); + n3.borrow_mut().next = Some(n4.clone()); + ``` + +=== "C" + + ```c title="linked_list.c" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + ListNode* n0 = newListNode(1); + ListNode* n1 = newListNode(3); + ListNode* n2 = newListNode(2); + ListNode* n3 = newListNode(5); + ListNode* n4 = newListNode(4); + // 노드 사이의 참조 설정 + n0->next = n1; + n1->next = n2; + n2->next = n3; + n3->next = n4; + ``` + +=== "Kotlin" + + ```kotlin title="linked_list.kt" + /* 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 */ + // 각 노드 초기화 + val n0 = ListNode(1) + val n1 = ListNode(3) + val n2 = ListNode(2) + val n3 = ListNode(5) + val n4 = ListNode(4) + // 노드 사이의 참조 설정 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "Ruby" + + ```ruby title="linked_list.rb" + # 연결 리스트 1 -> 3 -> 2 -> 5 -> 4 초기화 + # 각 노드 초기화 + n0 = ListNode.new(1) + n1 = ListNode.new(3) + n2 = ListNode.new(2) + n3 = ListNode.new(5) + n4 = ListNode.new(4) + # 노드 사이의 참조 설정 + n0.next = n1 + n1.next = n2 + n2.next = n3 + n3.next = n4 + ``` + +??? pythontutor "코드 시각화" + +
+ + +배열은 전체가 하나의 변수다. 예를 들어 배열 `nums`에는 `nums[0]`, `nums[1]` 등의 원소가 들어 있다. 반면 연결 리스트는 독립된 여러 노드 객체로 이루어진다. **보통 머리 노드로 연결 리스트 전체를 지칭한다**. 예를 들어 위 코드의 연결 리스트는 연결 리스트 `n0`라고 부를 수 있다. + +### 2.   노드 삽입 + +연결 리스트에 노드를 삽입하기는 매우 쉽다. 아래 그림(4-6)과 같이 인접한 노드 `n0`와 `n1` 사이에 새 노드 `P`를 삽입하려면 **노드 참조(포인터) 두 개만 바꾸면 된다**. 시간 복잡도는 $O(1)$이다. + +반면 배열의 원소 삽입은 시간 복잡도가 $O(n)$이므로 데이터가 많을 때 효율이 낮다. + +![연결 리스트에 노드를 삽입하는 예](linked_list.assets/linkedlist_insert_node.png){ class="animation-figure" } + +

그림 4-6   연결 리스트에 노드를 삽입하는 예

+ +=== "Python" + + ```python title="linked_list.py" + def insert(n0: ListNode, P: ListNode): + """연결 리스트의 노드 n0 뒤에 노드 P 삽입""" + n1 = n0.next + P.next = n1 + n0.next = P + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + void insert(ListNode *n0, ListNode *P) { + ListNode *n1 = n0->next; + P->next = n1; + n0->next = P; + } + ``` + +=== "Java" + + ```java title="linked_list.java" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + void insert(ListNode n0, ListNode P) { + ListNode n1 = n0.next; + P.next = n1; + n0.next = P; + } + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + void Insert(ListNode n0, ListNode P) { + ListNode? n1 = n0.next; + P.next = n1; + n0.next = P; + } + ``` + +=== "Go" + + ```go title="linked_list.go" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + func insertNode(n0 *ListNode, P *ListNode) { + n1 := n0.Next + P.Next = n1 + n0.Next = P + } + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + func insert(n0: ListNode, P: ListNode) { + let n1 = n0.next + P.next = n1 + n0.next = P + } + ``` + +=== "JS" + + ```javascript title="linked_list.js" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + function insert(n0, P) { + const n1 = n0.next; + P.next = n1; + n0.next = P; + } + ``` + +=== "TS" + + ```typescript title="linked_list.ts" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + function insert(n0: ListNode, P: ListNode): void { + const n1 = n0.next; + P.next = n1; + n0.next = P; + } + ``` + +=== "Dart" + + ```dart title="linked_list.dart" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + void insert(ListNode n0, ListNode P) { + ListNode? n1 = n0.next; + P.next = n1; + n0.next = P; + } + ``` + +=== "Rust" + + ```rust title="linked_list.rs" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + #[allow(non_snake_case)] + pub fn insert(n0: &Rc>>, P: Rc>>) { + let n1 = n0.borrow_mut().next.take(); + P.borrow_mut().next = n1; + n0.borrow_mut().next = Some(P); + } + ``` + +=== "C" + + ```c title="linked_list.c" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + void insert(ListNode *n0, ListNode *P) { + ListNode *n1 = n0->next; + P->next = n1; + n0->next = P; + } + ``` + +=== "Kotlin" + + ```kotlin title="linked_list.kt" + /* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */ + fun insert(n0: ListNode?, p: ListNode?) { + val n1 = n0?.next + p?.next = n1 + n0?.next = p + } + ``` + +=== "Ruby" + + ```ruby title="linked_list.rb" + ### 연결 리스트의 노드 n0 뒤에 노드 _p 삽입 ### + # Ruby에서 `p`는 내장 함수이고 `P`는 상수이므로, 대신 `_p`를 사용할 수 있다 + def insert(n0, _p) + n1 = n0.next + _p.next = n1 + n0.next = _p + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 3.   노드 삭제 + +아래 그림(4-7)과 같이 연결 리스트의 노드 삭제도 매우 간단하다. **노드 하나의 참조(포인터)만 바꾸면 된다**. + +삭제 후에도 노드 `P`는 여전히 `n1`을 가리키지만, 연결 리스트를 순회해서는 더 이상 `P`에 접근할 수 없다. 따라서 `P`는 이미 이 연결 리스트에 속하지 않는다. + +![연결 리스트의 노드 삭제](linked_list.assets/linkedlist_remove_node.png){ class="animation-figure" } + +

그림 4-7   연결 리스트의 노드 삭제

+ +=== "Python" + + ```python title="linked_list.py" + def remove(n0: ListNode): + """연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제""" + if not n0.next: + return + # n0 -> P -> n1 + P = n0.next + n1 = P.next + n0.next = n1 + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + void remove(ListNode *n0) { + if (n0->next == nullptr) + return; + // n0 -> P -> n1 + ListNode *P = n0->next; + ListNode *n1 = P->next; + n0->next = n1; + // 메모리 해제 + delete P; + } + ``` + +=== "Java" + + ```java title="linked_list.java" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + void remove(ListNode n0) { + if (n0.next == null) + return; + // n0 -> P -> n1 + ListNode P = n0.next; + ListNode n1 = P.next; + n0.next = n1; + } + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + void Remove(ListNode n0) { + if (n0.next == null) + return; + // n0 -> P -> n1 + ListNode P = n0.next; + ListNode? n1 = P.next; + n0.next = n1; + } + ``` + +=== "Go" + + ```go title="linked_list.go" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + func removeItem(n0 *ListNode) { + if n0.Next == nil { + return + } + // n0 -> P -> n1 + P := n0.Next + n1 := P.Next + n0.Next = n1 + } + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + func remove(n0: ListNode) { + if n0.next == nil { + return + } + // n0 -> P -> n1 + let P = n0.next + let n1 = P?.next + n0.next = n1 + } + ``` + +=== "JS" + + ```javascript title="linked_list.js" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + function remove(n0) { + if (!n0.next) return; + // n0 -> P -> n1 + const P = n0.next; + const n1 = P.next; + n0.next = n1; + } + ``` + +=== "TS" + + ```typescript title="linked_list.ts" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + function remove(n0: ListNode): void { + if (!n0.next) { + return; + } + // n0 -> P -> n1 + const P = n0.next; + const n1 = P.next; + n0.next = n1; + } + ``` + +=== "Dart" + + ```dart title="linked_list.dart" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + void remove(ListNode n0) { + if (n0.next == null) return; + // n0 -> P -> n1 + ListNode P = n0.next!; + ListNode? n1 = P.next; + n0.next = n1; + } + ``` + +=== "Rust" + + ```rust title="linked_list.rs" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + #[allow(non_snake_case)] + pub fn remove(n0: &Rc>>) { + // n0 -> P -> n1 + let P = n0.borrow_mut().next.take(); + if let Some(node) = P { + let n1 = node.borrow_mut().next.take(); + n0.borrow_mut().next = n1; + } + } + ``` + +=== "C" + + ```c title="linked_list.c" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + // 주의: stdio.h가 remove라는 이름을 사용한다 + void removeItem(ListNode *n0) { + if (!n0->next) + return; + // n0 -> P -> n1 + ListNode *P = n0->next; + ListNode *n1 = P->next; + n0->next = n1; + // 메모리 해제 + free(P); + } + ``` + +=== "Kotlin" + + ```kotlin title="linked_list.kt" + /* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */ + fun remove(n0: ListNode?) { + if (n0?.next == null) + return + // n0 -> P -> n1 + val p = n0.next + val n1 = p?.next + n0.next = n1 + } + ``` + +=== "Ruby" + + ```ruby title="linked_list.rb" + ### 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 ### + def remove(n0) + return if n0.next.nil? + + # n0 -> remove_node -> n1 + remove_node = n0.next + n1 = remove_node.next + n0.next = n1 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 4.   노드 접근 + +**연결 리스트의 노드 접근 효율은 낮다**. 앞 절에서 설명했듯 배열은 어떤 원소든 $O(1)$ 시간에 접근할 수 있다. 그러나 연결 리스트에서는 머리 노드부터 하나씩 뒤로 순회하여 목표 노드를 찾아야 한다. 즉, $i$번째 노드에 접근하려면 $i - 1$번 반복해야 하므로 시간 복잡도는 $O(n)$이다. + +=== "Python" + + ```python title="linked_list.py" + def access(head: ListNode, index: int) -> ListNode | None: + """연결 리스트에서 인덱스가 index인 노드에 접근""" + for _ in range(index): + if not head: + return None + head = head.next + return head + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + ListNode *access(ListNode *head, int index) { + for (int i = 0; i < index; i++) { + if (head == nullptr) + return nullptr; + head = head->next; + } + return head; + } + ``` + +=== "Java" + + ```java title="linked_list.java" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + ListNode access(ListNode head, int index) { + for (int i = 0; i < index; i++) { + if (head == null) + return null; + head = head.next; + } + return head; + } + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + ListNode? Access(ListNode? head, int index) { + for (int i = 0; i < index; i++) { + if (head == null) + return null; + head = head.next; + } + return head; + } + ``` + +=== "Go" + + ```go title="linked_list.go" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + func access(head *ListNode, index int) *ListNode { + for i := 0; i < index; i++ { + if head == nil { + return nil + } + head = head.Next + } + return head + } + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + func access(head: ListNode, index: Int) -> ListNode? { + var head: ListNode? = head + for _ in 0 ..< index { + if head == nil { + return nil + } + head = head?.next + } + return head + } + ``` + +=== "JS" + + ```javascript title="linked_list.js" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + function access(head, index) { + for (let i = 0; i < index; i++) { + if (!head) { + return null; + } + head = head.next; + } + return head; + } + ``` + +=== "TS" + + ```typescript title="linked_list.ts" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + function access(head: ListNode | null, index: number): ListNode | null { + for (let i = 0; i < index; i++) { + if (!head) { + return null; + } + head = head.next; + } + return head; + } + ``` + +=== "Dart" + + ```dart title="linked_list.dart" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + ListNode? access(ListNode? head, int index) { + for (var i = 0; i < index; i++) { + if (head == null) return null; + head = head.next; + } + return head; + } + ``` + +=== "Rust" + + ```rust title="linked_list.rs" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + pub fn access(head: Rc>>, index: i32) -> Option>>> { + fn dfs( + head: Option<&Rc>>>, + index: i32, + ) -> Option>>> { + if index <= 0 { + return head.cloned(); + } + + if let Some(node) = head { + dfs(node.borrow().next.as_ref(), index - 1) + } else { + None + } + } + + dfs(Some(head).as_ref(), index) + } + ``` + +=== "C" + + ```c title="linked_list.c" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + ListNode *access(ListNode *head, int index) { + for (int i = 0; i < index; i++) { + if (head == NULL) + return NULL; + head = head->next; + } + return head; + } + ``` + +=== "Kotlin" + + ```kotlin title="linked_list.kt" + /* 연결 리스트에서 인덱스가 index인 노드에 접근 */ + fun access(head: ListNode?, index: Int): ListNode? { + var h = head + for (i in 0.. + + +### 5.   노드 탐색 + +연결 리스트를 순회하여 값이 `target`인 노드를 찾고, 그 노드의 인덱스를 출력한다. 이 과정도 선형 탐색에 해당한다. 코드는 다음과 같다. + +=== "Python" + + ```python title="linked_list.py" + def find(head: ListNode, target: int) -> int: + """연결 리스트에서 값이 target인 첫 번째 노드 탐색""" + index = 0 + while head: + if head.val == target: + return index + head = head.next + index += 1 + return -1 + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + int find(ListNode *head, int target) { + int index = 0; + while (head != nullptr) { + if (head->val == target) + return index; + head = head->next; + index++; + } + return -1; + } + ``` + +=== "Java" + + ```java title="linked_list.java" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + int find(ListNode head, int target) { + int index = 0; + while (head != null) { + if (head.val == target) + return index; + head = head.next; + index++; + } + return -1; + } + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + int Find(ListNode? head, int target) { + int index = 0; + while (head != null) { + if (head.val == target) + return index; + head = head.next; + index++; + } + return -1; + } + ``` + +=== "Go" + + ```go title="linked_list.go" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + func findNode(head *ListNode, target int) int { + index := 0 + for head != nil { + if head.Val == target { + return index + } + head = head.Next + index++ + } + return -1 + } + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + func find(head: ListNode, target: Int) -> Int { + var head: ListNode? = head + var index = 0 + while head != nil { + if head?.val == target { + return index + } + head = head?.next + index += 1 + } + return -1 + } + ``` + +=== "JS" + + ```javascript title="linked_list.js" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + function find(head, target) { + let index = 0; + while (head !== null) { + if (head.val === target) { + return index; + } + head = head.next; + index += 1; + } + return -1; + } + ``` + +=== "TS" + + ```typescript title="linked_list.ts" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + function find(head: ListNode | null, target: number): number { + let index = 0; + while (head !== null) { + if (head.val === target) { + return index; + } + head = head.next; + index += 1; + } + return -1; + } + ``` + +=== "Dart" + + ```dart title="linked_list.dart" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + int find(ListNode? head, int target) { + int index = 0; + while (head != null) { + if (head.val == target) { + return index; + } + head = head.next; + index++; + } + return -1; + } + ``` + +=== "Rust" + + ```rust title="linked_list.rs" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + pub fn find(head: Rc>>, target: T) -> i32 { + fn find(head: Option<&Rc>>>, target: T, idx: i32) -> i32 { + if let Some(node) = head { + if node.borrow().val == target { + return idx; + } + return find(node.borrow().next.as_ref(), target, idx + 1); + } else { + -1 + } + } + + find(Some(head).as_ref(), target, 0) + } + ``` + +=== "C" + + ```c title="linked_list.c" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + int find(ListNode *head, int target) { + int index = 0; + while (head) { + if (head->val == target) + return index; + head = head->next; + index++; + } + return -1; + } + ``` + +=== "Kotlin" + + ```kotlin title="linked_list.kt" + /* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */ + fun find(head: ListNode?, target: Int): Int { + var index = 0 + var h = head + while (h != null) { + if (h._val == target) + return index + h = h.next + index++ + } + return -1 + } + ``` + +=== "Ruby" + + ```ruby title="linked_list.rb" + ### 연결 리스트에서 값이 target인 첫 번째 노드 탐색 ### + def find(head, target) + index = 0 + while head + return index if head.val == target + head = head.next + index += 1 + end + + -1 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 4.2.2   배열 vs. 연결 리스트 + +아래 표(4-1)는 배열과 연결 리스트의 특징과 연산 효율을 비교한다. 둘은 서로 반대되는 저장 전략을 사용하므로 성질과 연산 효율도 대조적이다. + +

표 4-1   배열과 연결 리스트의 효율 비교

+ +
+ +| | 배열 | 연결 리스트 | +| -------- | ------------------------------ | -------------- | +| 저장 방식 | 연속된 메모리 공간 | 분산된 메모리 공간 | +| 용량 확장 | 길이를 바꿀 수 없음 | 유연하게 확장 가능 | +| 메모리 효율 | 원소의 메모리 사용량은 적지만 공간을 낭비할 수 있음 | 원소의 메모리 사용량이 많음 | +| 원소 접근 | $O(1)$ | $O(n)$ | +| 원소 추가 | $O(n)$ | $O(1)$ | +| 원소 삭제 | $O(n)$ | $O(1)$ | + +
+ +## 4.2.3   대표적인 연결 리스트 종류 + +아래 그림(4-8)과 같이 대표적인 연결 리스트에는 세 종류가 있다. + +- **단일 연결 리스트**: 앞에서 소개한 일반적인 연결 리스트다. 각 노드는 값과 다음 노드를 가리키는 참조라는 두 데이터를 포함한다. 첫 노드는 머리 노드, 마지막 노드는 꼬리 노드이며, 꼬리 노드는 `None`을 가리킨다. +- **원형 연결 리스트**: 단일 연결 리스트의 꼬리 노드가 머리 노드를 가리키도록 앞뒤를 이으면 원형 연결 리스트가 된다. 원형 연결 리스트에서는 어느 노드든 머리 노드로 볼 수 있다. +- **이중 연결 리스트**: 단일 연결 리스트와 달리 양방향 참조를 기록한다. 각 노드는 후속 노드(다음 노드)와 선행 노드(이전 노드)를 가리키는 참조(포인터)를 모두 포함한다. 양방향으로 순회할 수 있어 단일 연결 리스트보다 유연하지만, 그만큼 메모리 공간도 더 많이 차지한다. + +=== "Python" + + ```python title="" + class ListNode: + """이중 연결 리스트 노드 클래스""" + def __init__(self, val: int): + self.val: int = val # 노드 값 + self.next: ListNode | None = None # 후속 노드를 가리키는 참조 + self.prev: ListNode | None = None # 선행 노드를 가리키는 참조 + ``` + +=== "C++" + + ```cpp title="" + /* 이중 연결 리스트 노드 구조체 */ + struct ListNode { + int val; // 노드 값 + ListNode *next; // 후속 노드를 가리키는 포인터 + ListNode *prev; // 선행 노드를 가리키는 포인터 + ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // 생성자 + }; + ``` + +=== "Java" + + ```java title="" + /* 이중 연결 리스트 노드 클래스 */ + class ListNode { + int val; // 노드 값 + ListNode next; // 후속 노드를 가리키는 참조 + ListNode prev; // 선행 노드를 가리키는 참조 + ListNode(int x) { val = x; } // 생성자 + } + ``` + +=== "C#" + + ```csharp title="" + /* 이중 연결 리스트 노드 클래스 */ + class ListNode(int x) { // 생성자 + int val = x; // 노드 값 + ListNode next; // 후속 노드를 가리키는 참조 + ListNode prev; // 선행 노드를 가리키는 참조 + } + ``` + +=== "Go" + + ```go title="" + /* 이중 연결 리스트 노드 구조체 */ + type DoublyListNode struct { + Val int // 노드 값 + Next *DoublyListNode // 후속 노드를 가리키는 포인터 + Prev *DoublyListNode // 선행 노드를 가리키는 포인터 + } + + // NewDoublyListNode 초기화 + func NewDoublyListNode(val int) *DoublyListNode { + return &DoublyListNode{ + Val: val, + Next: nil, + Prev: nil, + } + } + ``` + +=== "Swift" + + ```swift title="" + /* 이중 연결 리스트 노드 클래스 */ + class ListNode { + var val: Int // 노드 값 + var next: ListNode? // 후속 노드를 가리키는 참조 + var prev: ListNode? // 선행 노드를 가리키는 참조 + + init(x: Int) { // 생성자 + val = x + } + } + ``` + +=== "JS" + + ```javascript title="" + /* 이중 연결 리스트 노드 클래스 */ + class ListNode { + constructor(val, next, prev) { + this.val = val === undefined ? 0 : val; // 노드 값 + this.next = next === undefined ? null : next; // 후속 노드를 가리키는 참조 + this.prev = prev === undefined ? null : prev; // 선행 노드를 가리키는 참조 + } + } + ``` + +=== "TS" + + ```typescript title="" + /* 이중 연결 리스트 노드 클래스 */ + class ListNode { + val: number; + next: ListNode | null; + prev: ListNode | null; + constructor(val?: number, next?: ListNode | null, prev?: ListNode | null) { + this.val = val === undefined ? 0 : val; // 노드 값 + this.next = next === undefined ? null : next; // 후속 노드를 가리키는 참조 + this.prev = prev === undefined ? null : prev; // 선행 노드를 가리키는 참조 + } + } + ``` + +=== "Dart" + + ```dart title="" + /* 이중 연결 리스트 노드 클래스 */ + class ListNode { + int val; // 노드 값 + ListNode? next; // 후속 노드를 가리키는 참조 + ListNode? prev; // 선행 노드를 가리키는 참조 + ListNode(this.val, [this.next, this.prev]); // 생성자 + } + ``` + +=== "Rust" + + ```rust title="" + use std::rc::Rc; + use std::cell::RefCell; + + /* 이중 연결 리스트 노드 자료형 */ + #[derive(Debug)] + struct ListNode { + val: i32, // 노드 값 + next: Option>>, // 후속 노드를 가리키는 포인터 + prev: Option>>, // 선행 노드를 가리키는 포인터 + } + + /* 생성자 */ + impl ListNode { + fn new(val: i32) -> Self { + ListNode { + val, + next: None, + prev: None, + } + } + } + ``` + +=== "C" + + ```c title="" + /* 이중 연결 리스트 노드 구조체 */ + typedef struct ListNode { + int val; // 노드 값 + struct ListNode *next; // 후속 노드를 가리키는 포인터 + struct ListNode *prev; // 선행 노드를 가리키는 포인터 + } ListNode; + + /* 생성자 */ + ListNode *newListNode(int val) { + ListNode *node; + node = (ListNode *) malloc(sizeof(ListNode)); + node->val = val; + node->next = NULL; + node->prev = NULL; + return node; + } + ``` + +=== "Kotlin" + + ```kotlin title="" + /* 이중 연결 리스트 노드 클래스 */ + // 생성자 + class ListNode(x: Int) { + val _val: Int = x // 노드 값 + var next: ListNode? = null // 후속 노드를 가리키는 참조 + var prev: ListNode? = null // 선행 노드를 가리키는 참조 + } + ``` + +=== "Ruby" + + ```ruby title="" + # 이중 연결 리스트 노드 클래스 + class ListNode + attr_accessor :val # 노드 값 + attr_accessor :next # 후속 노드를 가리키는 참조 + attr_accessor :prev # 선행 노드를 가리키는 참조 + + def initialize(val=0, next_node=nil, prev_node=nil) + @val = val + @next = next_node + @prev = prev_node + end + end + ``` + +![대표적인 연결 리스트 종류](linked_list.assets/linkedlist_common_types.png){ class="animation-figure" } + +

그림 4-8   대표적인 연결 리스트 종류

+ +## 4.2.4   연결 리스트의 대표적인 활용 + +단일 연결 리스트는 보통 스택, 큐, 해시 테이블, 그래프 등의 자료 구조를 구현하는 데 사용된다. + +- **스택과 큐**: 삽입과 삭제를 모두 연결 리스트의 같은 끝에서 수행하면 후입선출 특성이 나타나 스택에 해당한다. 한쪽 끝에서 삽입하고 반대쪽 끝에서 삭제하면 선입선출 특성이 나타나 큐에 해당한다. +- **해시 테이블**: 체이닝은 해시 충돌을 해결하는 대표적인 방법 중 하나로, 충돌하는 모든 원소를 하나의 연결 리스트에 넣는다. +- **그래프**: 인접 리스트는 그래프를 표현하는 흔한 방법이다. 각 정점에 연결 리스트 하나를 연결하고, 그 리스트의 각 원소로 해당 정점과 연결된 다른 정점을 나타낸다. + +이중 연결 리스트는 이전 원소와 다음 원소를 빠르게 찾아야 하는 상황에 자주 사용된다. + +- **고급 자료 구조**: 레드-블랙 트리나 B 트리에서는 노드의 부모 노드에 접근해야 한다. 이중 연결 리스트처럼 노드에 부모를 가리키는 참조를 저장하여 구현할 수 있다. +- **브라우저 방문 기록**: 사용자가 웹 브라우저의 앞으로 또는 뒤로 버튼을 누르면 이전이나 다음에 방문했던 페이지를 알아야 한다. 이중 연결 리스트를 사용하면 이러한 연산이 간단해진다. +- **LRU 알고리즘**: 캐시 교체 알고리즘인 LRU에서는 가장 오랫동안 사용하지 않은 데이터를 빠르게 찾고 노드도 빠르게 추가하고 삭제해야 한다. 이때 이중 연결 리스트가 매우 적합하다. + +원형 연결 리스트는 운영체제의 자원 스케줄링처럼 주기적인 연산이 필요한 상황에 자주 사용된다. + +- **라운드 로빈 스케줄링 알고리즘**: 운영체제의 대표적인 CPU 스케줄링 알고리즘으로, 여러 프로세스를 순환한다. 각 프로세스에 타임 슬라이스를 할당하고, 시간이 다 되면 CPU가 다음 프로세스로 전환한다. 이러한 순환은 원형 연결 리스트로 구현할 수 있다. +- **데이터 버퍼**: 일부 데이터 버퍼도 원형 연결 리스트로 구현할 수 있다. 예를 들어 오디오나 동영상 플레이어에서 데이터 스트림을 여러 버퍼 블록으로 나누어 원형 연결 리스트에 넣으면 끊김 없이 재생할 수 있다. diff --git a/ko/docs/chapter_array_and_linkedlist/list.md b/ko/docs/chapter_array_and_linkedlist/list.md new file mode 100644 index 000000000..aaf8589cd --- /dev/null +++ b/ko/docs/chapter_array_and_linkedlist/list.md @@ -0,0 +1,2301 @@ +--- +comments: true +--- + +# 4.3   리스트 + +리스트(list)는 순서가 있는 원소의 모음을 나타내는 추상적인 자료 구조 개념이다. 원소 접근, 수정, 추가, 삭제, 순회 등의 연산을 지원하며, 사용자가 용량 제한을 신경 쓰지 않아도 된다. 리스트는 연결 리스트나 배열로 구현할 수 있다. + +- 연결 리스트는 원소의 삽입, 삭제, 탐색, 갱신을 지원하고 유연하게 용량을 늘릴 수 있으므로 자연스럽게 리스트로 볼 수 있다. +- 배열도 원소의 삽입, 삭제, 탐색, 갱신을 지원하지만 길이를 바꿀 수 없으므로 길이가 제한된 리스트로 볼 수 있다. + +배열로 리스트를 구현하면 **길이를 바꿀 수 없다는 특성 때문에 실용성이 낮아진다**. 보통 저장할 데이터 양을 미리 알 수 없어 적절한 길이를 정하기 어렵기 때문이다. 길이가 너무 짧으면 필요를 충족하지 못하고, 너무 길면 메모리 공간을 낭비한다. + +이 문제는 동적 배열(dynamic array)로 리스트를 구현하여 해결할 수 있다. 동적 배열은 배열의 장점을 이어받으면서도 프로그램 실행 중에 용량을 동적으로 늘릴 수 있다. + +실제로 **많은 프로그래밍 언어의 표준 라이브러리가 제공하는 리스트는 동적 배열로 구현된다**. Python의 `list`, Java의 `ArrayList`, C++의 `vector`, C#의 `List` 등이 그 예다. 이후 설명에서는 ‘리스트’와 ‘동적 배열’을 같은 개념으로 다룬다. + +## 4.3.1   리스트의 기본 연산 + +### 1.   리스트 초기화 + +보통 ‘초깃값 없음’과 ‘초깃값 지정’의 두 가지 초기화 방식을 사용한다. + +=== "Python" + + ```python title="list.py" + # 리스트 초기화 + # 초깃값 없음 + nums1: list[int] = [] + # 초깃값 지정 + nums: list[int] = [1, 3, 2, 5, 4] + ``` + +=== "C++" + + ```cpp title="list.cpp" + /* 리스트 초기화 */ + // C++의 vector가 이 글에서 설명하는 nums에 해당한다 + // 초깃값 없음 + vector nums1; + // 초깃값 지정 + vector nums = { 1, 3, 2, 5, 4 }; + ``` + +=== "Java" + + ```java title="list.java" + /* 리스트 초기화 */ + // 초깃값 없음 + List nums1 = new ArrayList<>(); + // 초깃값 지정(배열은 int[]의 래퍼형인 Integer[]여야 함) + Integer[] numbers = new Integer[] { 1, 3, 2, 5, 4 }; + List nums = new ArrayList<>(Arrays.asList(numbers)); + ``` + +=== "C#" + + ```csharp title="list.cs" + /* 리스트 초기화 */ + // 초깃값 없음 + List nums1 = []; + // 초깃값 지정 + int[] numbers = [1, 3, 2, 5, 4]; + List nums = [.. numbers]; + ``` + +=== "Go" + + ```go title="list_test.go" + /* 리스트 초기화 */ + // 초깃값 없음 + nums1 := []int{} + // 초깃값 지정 + nums := []int{1, 3, 2, 5, 4} + ``` + +=== "Swift" + + ```swift title="list.swift" + /* 리스트 초기화 */ + // 초깃값 없음 + let nums1: [Int] = [] + // 초깃값 지정 + var nums = [1, 3, 2, 5, 4] + ``` + +=== "JS" + + ```javascript title="list.js" + /* 리스트 초기화 */ + // 초깃값 없음 + const nums1 = []; + // 초깃값 지정 + const nums = [1, 3, 2, 5, 4]; + ``` + +=== "TS" + + ```typescript title="list.ts" + /* 리스트 초기화 */ + // 초깃값 없음 + const nums1: number[] = []; + // 초깃값 지정 + const nums: number[] = [1, 3, 2, 5, 4]; + ``` + +=== "Dart" + + ```dart title="list.dart" + /* 리스트 초기화 */ + // 초깃값 없음 + List nums1 = []; + // 초깃값 지정 + List nums = [1, 3, 2, 5, 4]; + ``` + +=== "Rust" + + ```rust title="list.rs" + /* 리스트 초기화 */ + // 초깃값 없음 + let nums1: Vec = Vec::new(); + // 초깃값 지정 + let nums: Vec = vec![1, 3, 2, 5, 4]; + ``` + +=== "C" + + ```c title="list.c" + // C는 내장 동적 배열을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="list.kt" + /* 리스트 초기화 */ + // 초깃값 없음 + var nums1 = listOf() + // 초깃값 지정 + var numbers = arrayOf(1, 3, 2, 5, 4) + var nums = numbers.toMutableList() + ``` + +=== "Ruby" + + ```ruby title="list.rb" + # 리스트 초기화 + # 초깃값 없음 + nums1 = [] + # 초깃값 지정 + nums = [1, 3, 2, 5, 4] + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 2.   원소 접근 + +리스트는 본질적으로 배열이므로 원소에 접근하고 갱신하는 데 $O(1)$ 시간이 걸려 매우 효율적이다. + +=== "Python" + + ```python title="list.py" + # 원소 접근 + num: int = nums[1] # 인덱스 1의 원소에 접근 + + # 원소 갱신 + nums[1] = 0 # 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "C++" + + ```cpp title="list.cpp" + /* 원소 접근 */ + int num = nums[1]; // 인덱스 1의 원소에 접근 + + /* 원소 갱신 */ + nums[1] = 0; // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "Java" + + ```java title="list.java" + /* 원소 접근 */ + int num = nums.get(1); // 인덱스 1의 원소에 접근 + + /* 원소 갱신 */ + nums.set(1, 0); // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "C#" + + ```csharp title="list.cs" + /* 원소 접근 */ + int num = nums[1]; // 인덱스 1의 원소에 접근 + + /* 원소 갱신 */ + nums[1] = 0; // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "Go" + + ```go title="list_test.go" + /* 원소 접근 */ + num := nums[1] // 인덱스 1의 원소에 접근 + + /* 원소 갱신 */ + nums[1] = 0 // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "Swift" + + ```swift title="list.swift" + /* 원소 접근 */ + let num = nums[1] // 인덱스 1의 원소에 접근 + + /* 원소 갱신 */ + nums[1] = 0 // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "JS" + + ```javascript title="list.js" + /* 원소 접근 */ + const num = nums[1]; // 인덱스 1의 원소에 접근 + + /* 원소 갱신 */ + nums[1] = 0; // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "TS" + + ```typescript title="list.ts" + /* 원소 접근 */ + const num: number = nums[1]; // 인덱스 1의 원소에 접근 + + /* 원소 갱신 */ + nums[1] = 0; // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "Dart" + + ```dart title="list.dart" + /* 원소 접근 */ + int num = nums[1]; // 인덱스 1의 원소에 접근 + + /* 원소 갱신 */ + nums[1] = 0; // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "Rust" + + ```rust title="list.rs" + /* 원소 접근 */ + let num: i32 = nums[1]; // 인덱스 1의 원소에 접근 + /* 원소 갱신 */ + nums[1] = 0; // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "C" + + ```c title="list.c" + // C는 내장 동적 배열을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="list.kt" + /* 원소 접근 */ + val num = nums[1] // 인덱스 1의 원소에 접근 + /* 원소 갱신 */ + nums[1] = 0 // 인덱스 1의 원소를 0으로 갱신 + ``` + +=== "Ruby" + + ```ruby title="list.rb" + # 원소 접근 + num = nums[1] # 인덱스 1의 원소에 접근 + # 원소 갱신 + nums[1] = 0 # 인덱스 1의 원소를 0으로 갱신 + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 3.   원소 삽입과 삭제 + +배열과 달리 리스트는 원소를 자유롭게 추가하고 삭제할 수 있다. 끝에 원소를 추가하는 시간 복잡도는 $O(1)$이지만, 삽입과 삭제의 효율은 배열과 같아 시간 복잡도가 $O(n)$이다. + +=== "Python" + + ```python title="list.py" + # 리스트 비우기 + nums.clear() + + # 끝에 원소 추가 + nums.append(1) + nums.append(3) + nums.append(2) + nums.append(5) + nums.append(4) + + # 중간에 원소 삽입 + nums.insert(3, 6) # 인덱스 3에 숫자 6 삽입 + + # 원소 삭제 + nums.pop(3) # 인덱스 3의 원소 삭제 + ``` + +=== "C++" + + ```cpp title="list.cpp" + /* 리스트 비우기 */ + nums.clear(); + + /* 끝에 원소 추가 */ + nums.push_back(1); + nums.push_back(3); + nums.push_back(2); + nums.push_back(5); + nums.push_back(4); + + /* 중간에 원소 삽입 */ + nums.insert(nums.begin() + 3, 6); // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums.erase(nums.begin() + 3); // 인덱스 3의 원소 삭제 + ``` + +=== "Java" + + ```java title="list.java" + /* 리스트 비우기 */ + nums.clear(); + + /* 끝에 원소 추가 */ + nums.add(1); + nums.add(3); + nums.add(2); + nums.add(5); + nums.add(4); + + /* 중간에 원소 삽입 */ + nums.add(3, 6); // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums.remove(3); // 인덱스 3의 원소 삭제 + ``` + +=== "C#" + + ```csharp title="list.cs" + /* 리스트 비우기 */ + nums.Clear(); + + /* 끝에 원소 추가 */ + nums.Add(1); + nums.Add(3); + nums.Add(2); + nums.Add(5); + nums.Add(4); + + /* 중간에 원소 삽입 */ + nums.Insert(3, 6); // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums.RemoveAt(3); // 인덱스 3의 원소 삭제 + ``` + +=== "Go" + + ```go title="list_test.go" + /* 리스트 비우기 */ + nums = nil + + /* 끝에 원소 추가 */ + nums = append(nums, 1) + nums = append(nums, 3) + nums = append(nums, 2) + nums = append(nums, 5) + nums = append(nums, 4) + + /* 중간에 원소 삽입 */ + nums = append(nums[:3], append([]int{6}, nums[3:]...)...) // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums = append(nums[:3], nums[4:]...) // 인덱스 3의 원소 삭제 + ``` + +=== "Swift" + + ```swift title="list.swift" + /* 리스트 비우기 */ + nums.removeAll() + + /* 끝에 원소 추가 */ + nums.append(1) + nums.append(3) + nums.append(2) + nums.append(5) + nums.append(4) + + /* 중간에 원소 삽입 */ + nums.insert(6, at: 3) // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums.remove(at: 3) // 인덱스 3의 원소 삭제 + ``` + +=== "JS" + + ```javascript title="list.js" + /* 리스트 비우기 */ + nums.length = 0; + + /* 끝에 원소 추가 */ + nums.push(1); + nums.push(3); + nums.push(2); + nums.push(5); + nums.push(4); + + /* 중간에 원소 삽입 */ + nums.splice(3, 0, 6); // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums.splice(3, 1); // 인덱스 3의 원소 삭제 + ``` + +=== "TS" + + ```typescript title="list.ts" + /* 리스트 비우기 */ + nums.length = 0; + + /* 끝에 원소 추가 */ + nums.push(1); + nums.push(3); + nums.push(2); + nums.push(5); + nums.push(4); + + /* 중간에 원소 삽입 */ + nums.splice(3, 0, 6); // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums.splice(3, 1); // 인덱스 3의 원소 삭제 + ``` + +=== "Dart" + + ```dart title="list.dart" + /* 리스트 비우기 */ + nums.clear(); + + /* 끝에 원소 추가 */ + nums.add(1); + nums.add(3); + nums.add(2); + nums.add(5); + nums.add(4); + + /* 중간에 원소 삽입 */ + nums.insert(3, 6); // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums.removeAt(3); // 인덱스 3의 원소 삭제 + ``` + +=== "Rust" + + ```rust title="list.rs" + /* 리스트 비우기 */ + nums.clear(); + + /* 끝에 원소 추가 */ + nums.push(1); + nums.push(3); + nums.push(2); + nums.push(5); + nums.push(4); + + /* 중간에 원소 삽입 */ + nums.insert(3, 6); // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums.remove(3); // 인덱스 3의 원소 삭제 + ``` + +=== "C" + + ```c title="list.c" + // C는 내장 동적 배열을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="list.kt" + /* 리스트 비우기 */ + nums.clear(); + + /* 끝에 원소 추가 */ + nums.add(1); + nums.add(3); + nums.add(2); + nums.add(5); + nums.add(4); + + /* 중간에 원소 삽입 */ + nums.add(3, 6); // 인덱스 3에 숫자 6 삽입 + + /* 원소 삭제 */ + nums.remove(3); // 인덱스 3의 원소 삭제 + ``` + +=== "Ruby" + + ```ruby title="list.rb" + # 리스트 비우기 + nums.clear + + # 끝에 원소 추가 + nums << 1 + nums << 3 + nums << 2 + nums << 5 + nums << 4 + + # 중간에 원소 삽입 + nums.insert(3, 6) # 인덱스 3에 숫자 6 삽입 + + # 원소 삭제 + nums.delete_at(3) # 인덱스 3의 원소 삭제 + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 4.   리스트 순회 + +배열처럼 리스트도 인덱스로 순회하거나 각 원소를 직접 순회할 수 있다. + +=== "Python" + + ```python title="list.py" + # 인덱스로 리스트 순회 + count = 0 + for i in range(len(nums)): + count += nums[i] + + # 리스트 원소를 직접 순회 + for num in nums: + count += num + ``` + +=== "C++" + + ```cpp title="list.cpp" + /* 인덱스로 리스트 순회 */ + int count = 0; + for (int i = 0; i < nums.size(); i++) { + count += nums[i]; + } + + /* 리스트 원소를 직접 순회 */ + count = 0; + for (int num : nums) { + count += num; + } + ``` + +=== "Java" + + ```java title="list.java" + /* 인덱스로 리스트 순회 */ + int count = 0; + for (int i = 0; i < nums.size(); i++) { + count += nums.get(i); + } + + /* 리스트 원소를 직접 순회 */ + for (int num : nums) { + count += num; + } + ``` + +=== "C#" + + ```csharp title="list.cs" + /* 인덱스로 리스트 순회 */ + int count = 0; + for (int i = 0; i < nums.Count; i++) { + count += nums[i]; + } + + /* 리스트 원소를 직접 순회 */ + count = 0; + foreach (int num in nums) { + count += num; + } + ``` + +=== "Go" + + ```go title="list_test.go" + /* 인덱스로 리스트 순회 */ + count := 0 + for i := 0; i < len(nums); i++ { + count += nums[i] + } + + /* 리스트 원소를 직접 순회 */ + count = 0 + for _, num := range nums { + count += num + } + ``` + +=== "Swift" + + ```swift title="list.swift" + /* 인덱스로 리스트 순회 */ + var count = 0 + for i in nums.indices { + count += nums[i] + } + + /* 리스트 원소를 직접 순회 */ + count = 0 + for num in nums { + count += num + } + ``` + +=== "JS" + + ```javascript title="list.js" + /* 인덱스로 리스트 순회 */ + let count = 0; + for (let i = 0; i < nums.length; i++) { + count += nums[i]; + } + + /* 리스트 원소를 직접 순회 */ + count = 0; + for (const num of nums) { + count += num; + } + ``` + +=== "TS" + + ```typescript title="list.ts" + /* 인덱스로 리스트 순회 */ + let count = 0; + for (let i = 0; i < nums.length; i++) { + count += nums[i]; + } + + /* 리스트 원소를 직접 순회 */ + count = 0; + for (const num of nums) { + count += num; + } + ``` + +=== "Dart" + + ```dart title="list.dart" + /* 인덱스로 리스트 순회 */ + int count = 0; + for (var i = 0; i < nums.length; i++) { + count += nums[i]; + } + + /* 리스트 원소를 직접 순회 */ + count = 0; + for (var num in nums) { + count += num; + } + ``` + +=== "Rust" + + ```rust title="list.rs" + // 인덱스로 리스트 순회 + let mut _count = 0; + for i in 0..nums.len() { + _count += nums[i]; + } + + // 리스트 원소를 직접 순회 + _count = 0; + for num in &nums { + _count += num; + } + ``` + +=== "C" + + ```c title="list.c" + // C는 내장 동적 배열을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="list.kt" + /* 인덱스로 리스트 순회 */ + var count = 0 + for (i in nums.indices) { + count += nums[i] + } + + /* 리스트 원소를 직접 순회 */ + for (num in nums) { + count += num + } + ``` + +=== "Ruby" + + ```ruby title="list.rb" + # 인덱스로 리스트 순회 + count = 0 + for i in 0...nums.length + count += nums[i] + end + + # 리스트 원소를 직접 순회 + count = 0 + for num in nums + count += num + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 5.   리스트 연결 + +새 리스트 `nums1`이 주어지면 기존 리스트의 끝에 이어 붙일 수 있다. + +=== "Python" + + ```python title="list.py" + # 두 리스트 연결 + nums1: list[int] = [6, 8, 7, 10, 9] + nums += nums1 # 리스트 nums1을 nums 뒤에 연결 + ``` + +=== "C++" + + ```cpp title="list.cpp" + /* 두 리스트 연결 */ + vector nums1 = { 6, 8, 7, 10, 9 }; + // 리스트 nums1을 nums 뒤에 연결 + nums.insert(nums.end(), nums1.begin(), nums1.end()); + ``` + +=== "Java" + + ```java title="list.java" + /* 두 리스트 연결 */ + List nums1 = new ArrayList<>(Arrays.asList(new Integer[] { 6, 8, 7, 10, 9 })); + nums.addAll(nums1); // 리스트 nums1을 nums 뒤에 연결 + ``` + +=== "C#" + + ```csharp title="list.cs" + /* 두 리스트 연결 */ + List nums1 = [6, 8, 7, 10, 9]; + nums.AddRange(nums1); // 리스트 nums1을 nums 뒤에 연결 + ``` + +=== "Go" + + ```go title="list_test.go" + /* 두 리스트 연결 */ + nums1 := []int{6, 8, 7, 10, 9} + nums = append(nums, nums1...) // 리스트 nums1을 nums 뒤에 연결 + ``` + +=== "Swift" + + ```swift title="list.swift" + /* 두 리스트 연결 */ + let nums1 = [6, 8, 7, 10, 9] + nums.append(contentsOf: nums1) // 리스트 nums1을 nums 뒤에 연결 + ``` + +=== "JS" + + ```javascript title="list.js" + /* 두 리스트 연결 */ + const nums1 = [6, 8, 7, 10, 9]; + nums.push(...nums1); // 리스트 nums1을 nums 뒤에 연결 + ``` + +=== "TS" + + ```typescript title="list.ts" + /* 두 리스트 연결 */ + const nums1: number[] = [6, 8, 7, 10, 9]; + nums.push(...nums1); // 리스트 nums1을 nums 뒤에 연결 + ``` + +=== "Dart" + + ```dart title="list.dart" + /* 두 리스트 연결 */ + List nums1 = [6, 8, 7, 10, 9]; + nums.addAll(nums1); // 리스트 nums1을 nums 뒤에 연결 + ``` + +=== "Rust" + + ```rust title="list.rs" + /* 두 리스트 연결 */ + let nums1: Vec = vec![6, 8, 7, 10, 9]; + nums.extend(nums1); + ``` + +=== "C" + + ```c title="list.c" + // C는 내장 동적 배열을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="list.kt" + /* 두 리스트 연결 */ + val nums1 = intArrayOf(6, 8, 7, 10, 9).toMutableList() + nums.addAll(nums1) // 리스트 nums1을 nums 뒤에 연결 + ``` + +=== "Ruby" + + ```ruby title="list.rb" + # 두 리스트 연결 + nums1 = [6, 8, 7, 10, 9] + nums += nums1 + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 6.   리스트 정렬 + +리스트를 정렬하면 배열 관련 알고리즘 문제에 자주 등장하는 ‘이진 탐색’과 ‘투 포인터’ 알고리즘을 사용할 수 있다. + +=== "Python" + + ```python title="list.py" + # 리스트 정렬 + nums.sort() # 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "C++" + + ```cpp title="list.cpp" + /* 리스트 정렬 */ + sort(nums.begin(), nums.end()); // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "Java" + + ```java title="list.java" + /* 리스트 정렬 */ + Collections.sort(nums); // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "C#" + + ```csharp title="list.cs" + /* 리스트 정렬 */ + nums.Sort(); // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "Go" + + ```go title="list_test.go" + /* 리스트 정렬 */ + sort.Ints(nums) // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "Swift" + + ```swift title="list.swift" + /* 리스트 정렬 */ + nums.sort() // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "JS" + + ```javascript title="list.js" + /* 리스트 정렬 */ + nums.sort((a, b) => a - b); // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "TS" + + ```typescript title="list.ts" + /* 리스트 정렬 */ + nums.sort((a, b) => a - b); // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "Dart" + + ```dart title="list.dart" + /* 리스트 정렬 */ + nums.sort(); // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "Rust" + + ```rust title="list.rs" + /* 리스트 정렬 */ + nums.sort(); // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "C" + + ```c title="list.c" + // C는 내장 동적 배열을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="list.kt" + /* 리스트 정렬 */ + nums.sort() // 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +=== "Ruby" + + ```ruby title="list.rb" + # 리스트 정렬 + nums = nums.sort { |a, b| a <=> b } # 정렬 후 리스트 원소가 오름차순으로 배치됨 + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 4.3.2   리스트 구현 + +Java, C++, Python 등 많은 프로그래밍 언어는 리스트를 내장하고 있다. 이들의 구현은 비교적 복잡하며 초기 용량, 확장 배수 등의 매개변수도 정교하게 설정되어 있다. 관심 있는 독자는 소스 코드를 살펴보며 학습할 수 있다. + +리스트의 작동 원리를 더 깊이 이해하기 위해 간단한 리스트를 구현해 보자. 핵심 설계는 다음 세 가지다. + +- **초기 용량**: 배열의 초기 용량을 적절히 선택한다. 이 예에서는 10을 초기 용량으로 정한다. +- **개수 기록**: 현재 원소 수를 기록하는 변수 `size`를 선언하고, 삽입과 삭제에 따라 즉시 갱신한다. 이 변수를 통해 리스트의 끝을 찾고 용량 확장이 필요한지 판단할 수 있다. +- **용량 확장 방식**: 원소를 삽입할 때 리스트가 가득 찼으면 용량을 늘려야 한다. 확장 배수에 따라 더 큰 배열을 만든 뒤 기존 원소를 모두 차례로 옮긴다. 이 예에서는 매번 배열 용량을 이전의 2배로 늘린다. + +=== "Python" + + ```python title="my_list.py" + class MyList: + """리스트 클래스""" + + def __init__(self): + """생성자""" + self._capacity: int = 10 # 리스트 용량 + self._arr: list[int] = [0] * self._capacity # 배열(리스트 원소 저장) + self._size: int = 0 # 리스트 길이(현재 원소 개수) + self._extend_ratio: int = 2 # 리스트의 용량을 확장할 때마다 적용하는 배수 + + def size(self) -> int: + """리스트 길이(현재 원소 개수) 반환""" + return self._size + + def capacity(self) -> int: + """리스트 용량 반환""" + return self._capacity + + def get(self, index: int) -> int: + """원소 접근""" + # 인덱스가 범위를 벗어나면 예외를 발생시킨다. 아래에서도 동일하다 + if index < 0 or index >= self._size: + raise IndexError("인덱스가 범위를 벗어났다") + return self._arr[index] + + def set(self, num: int, index: int): + """원소 갱신""" + if index < 0 or index >= self._size: + raise IndexError("인덱스가 범위를 벗어났다") + self._arr[index] = num + + def add(self, num: int): + """맨 뒤에 원소 추가""" + # 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if self.size() == self.capacity(): + self.extend_capacity() + self._arr[self._size] = num + self._size += 1 + + def insert(self, num: int, index: int): + """중간에 원소 삽입""" + if index < 0 or index >= self._size: + raise IndexError("인덱스가 범위를 벗어났다") + # 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if self._size == self.capacity(): + self.extend_capacity() + # 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for j in range(self._size - 1, index - 1, -1): + self._arr[j + 1] = self._arr[j] + self._arr[index] = num + # 원소 개수를 갱신한다 + self._size += 1 + + def remove(self, index: int) -> int: + """원소 삭제""" + if index < 0 or index >= self._size: + raise IndexError("인덱스가 범위를 벗어났다") + num = self._arr[index] + # 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for j in range(index, self._size - 1): + self._arr[j] = self._arr[j + 1] + # 원소 개수를 갱신한다 + self._size -= 1 + # 삭제한 원소를 반환한다 + return num + + def extend_capacity(self): + """리스트 용량 확장""" + # 원래 배열 길이의 _extend_ratio 배인 새 배열을 만들고 원래 배열을 새 배열에 복사한다 + self._arr = self._arr + [0] * self.capacity() * (self._extend_ratio - 1) + # 리스트 용량을 갱신한다 + self._capacity = len(self._arr) + + def to_array(self) -> list[int]: + """유효한 길이만큼의 리스트 반환""" + return self._arr[: self._size] + ``` + +=== "C++" + + ```cpp title="my_list.cpp" + /* 리스트 클래스 */ + class MyList { + private: + int *arr; // 배열(리스트 원소 저장) + int arrCapacity = 10; // 리스트 용량 + int arrSize = 0; // 리스트 길이(현재 원소 개수) + int extendRatio = 2; // 리스트의 용량을 확장할 때마다 적용하는 배수 + + public: + /* 생성자 */ + MyList() { + arr = new int[arrCapacity]; + } + + /* 소멸자 */ + ~MyList() { + delete[] arr; + } + + /* 리스트 길이(현재 원소 개수) 반환*/ + int size() { + return arrSize; + } + + /* 리스트 용량 반환 */ + int capacity() { + return arrCapacity; + } + + /* 원소 접근 */ + int get(int index) { + // 인덱스가 범위를 벗어나면 예외를 발생시킨다. 아래에서도 동일하다 + if (index < 0 || index >= size()) + throw out_of_range("인덱스가 범위를 벗어났다"); + return arr[index]; + } + + /* 원소 갱신 */ + void set(int index, int num) { + if (index < 0 || index >= size()) + throw out_of_range("인덱스가 범위를 벗어났다"); + arr[index] = num; + } + + /* 맨 뒤에 원소 추가 */ + void add(int num) { + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (size() == capacity()) + extendCapacity(); + arr[size()] = num; + // 원소 개수를 갱신한다 + arrSize++; + } + + /* 중간에 원소 삽입 */ + void insert(int index, int num) { + if (index < 0 || index >= size()) + throw out_of_range("인덱스가 범위를 벗어났다"); + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (size() == capacity()) + extendCapacity(); + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for (int j = size() - 1; j >= index; j--) { + arr[j + 1] = arr[j]; + } + arr[index] = num; + // 원소 개수를 갱신한다 + arrSize++; + } + + /* 원소 삭제 */ + int remove(int index) { + if (index < 0 || index >= size()) + throw out_of_range("인덱스가 범위를 벗어났다"); + int num = arr[index]; + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for (int j = index; j < size() - 1; j++) { + arr[j] = arr[j + 1]; + } + // 원소 개수를 갱신한다 + arrSize--; + // 삭제한 원소를 반환한다 + return num; + } + + /* 리스트 용량 확장 */ + void extendCapacity() { + // 길이가 기존 배열의 extendRatio배인 새 배열 생성 + int newCapacity = capacity() * extendRatio; + int *tmp = arr; + arr = new int[newCapacity]; + // 원래 배열의 모든 원소를 새 배열에 복사한다 + for (int i = 0; i < size(); i++) { + arr[i] = tmp[i]; + } + // 메모리 해제 + delete[] tmp; + arrCapacity = newCapacity; + } + + /* 출력을 위해 리스트를 Vector로 변환 */ + vector toVector() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + vector vec(size()); + for (int i = 0; i < size(); i++) { + vec[i] = arr[i]; + } + return vec; + } + }; + ``` + +=== "Java" + + ```java title="my_list.java" + /* 리스트 클래스 */ + class MyList { + private int[] arr; // 배열(리스트 원소 저장) + private int capacity = 10; // 리스트 용량 + private int size = 0; // 리스트 길이(현재 원소 개수) + private int extendRatio = 2; // 리스트의 용량을 확장할 때마다 적용하는 배수 + + /* 생성자 */ + public MyList() { + arr = new int[capacity]; + } + + /* 리스트 길이(현재 원소 개수) 반환 */ + public int size() { + return size; + } + + /* 리스트 용량 반환 */ + public int capacity() { + return capacity; + } + + /* 원소 접근 */ + public int get(int index) { + // 인덱스가 범위를 벗어나면 예외를 발생시킨다. 아래에서도 동일하다 + if (index < 0 || index >= size) + throw new IndexOutOfBoundsException("인덱스가 범위를 벗어났다"); + return arr[index]; + } + + /* 원소 갱신 */ + public void set(int index, int num) { + if (index < 0 || index >= size) + throw new IndexOutOfBoundsException("인덱스가 범위를 벗어났다"); + arr[index] = num; + } + + /* 맨 뒤에 원소 추가 */ + public void add(int num) { + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (size == capacity()) + extendCapacity(); + arr[size] = num; + // 원소 개수를 갱신한다 + size++; + } + + /* 중간에 원소 삽입 */ + public void insert(int index, int num) { + if (index < 0 || index >= size) + throw new IndexOutOfBoundsException("인덱스가 범위를 벗어났다"); + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (size == capacity()) + extendCapacity(); + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for (int j = size - 1; j >= index; j--) { + arr[j + 1] = arr[j]; + } + arr[index] = num; + // 원소 개수를 갱신한다 + size++; + } + + /* 원소 삭제 */ + public int remove(int index) { + if (index < 0 || index >= size) + throw new IndexOutOfBoundsException("인덱스가 범위를 벗어났다"); + int num = arr[index]; + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for (int j = index; j < size - 1; j++) { + arr[j] = arr[j + 1]; + } + // 원소 개수를 갱신한다 + size--; + // 삭제한 원소를 반환한다 + return num; + } + + /* 리스트 용량 확장 */ + public void extendCapacity() { + // 길이가 원래 배열의 extendRatio배인 새 배열을 생성하고, 원래 배열을 새 배열에 복사한다 + arr = Arrays.copyOf(arr, capacity() * extendRatio); + // 리스트 용량을 갱신한다 + capacity = arr.length; + } + + /* 리스트를 배열로 변환 */ + public int[] toArray() { + int size = size(); + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + int[] arr = new int[size]; + for (int i = 0; i < size; i++) { + arr[i] = get(i); + } + return arr; + } + } + ``` + +=== "C#" + + ```csharp title="my_list.cs" + /* 리스트 클래스 */ + class MyList { + private int[] arr; // 배열(리스트 원소 저장) + private int arrCapacity = 10; // 리스트 용량 + private int arrSize = 0; // 리스트 길이(현재 원소 개수) + private readonly int extendRatio = 2; // 리스트의 용량을 확장할 때마다 적용하는 배수 + + /* 생성자 */ + public MyList() { + arr = new int[arrCapacity]; + } + + /* 리스트 길이(현재 원소 개수) 반환*/ + public int Size() { + return arrSize; + } + + /* 리스트 용량 반환 */ + public int Capacity() { + return arrCapacity; + } + + /* 원소 접근 */ + public int Get(int index) { + // 인덱스가 범위를 벗어나면 예외를 발생시킨다. 아래에서도 동일하다 + if (index < 0 || index >= arrSize) + throw new IndexOutOfRangeException("인덱스가 범위를 벗어났다"); + return arr[index]; + } + + /* 원소 갱신 */ + public void Set(int index, int num) { + if (index < 0 || index >= arrSize) + throw new IndexOutOfRangeException("인덱스가 범위를 벗어났다"); + arr[index] = num; + } + + /* 맨 뒤에 원소 추가 */ + public void Add(int num) { + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (arrSize == arrCapacity) + ExtendCapacity(); + arr[arrSize] = num; + // 원소 개수를 갱신한다 + arrSize++; + } + + /* 중간에 원소 삽입 */ + public void Insert(int index, int num) { + if (index < 0 || index >= arrSize) + throw new IndexOutOfRangeException("인덱스가 범위를 벗어났다"); + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (arrSize == arrCapacity) + ExtendCapacity(); + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for (int j = arrSize - 1; j >= index; j--) { + arr[j + 1] = arr[j]; + } + arr[index] = num; + // 원소 개수를 갱신한다 + arrSize++; + } + + /* 원소 삭제 */ + public int Remove(int index) { + if (index < 0 || index >= arrSize) + throw new IndexOutOfRangeException("인덱스가 범위를 벗어났다"); + int num = arr[index]; + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for (int j = index; j < arrSize - 1; j++) { + arr[j] = arr[j + 1]; + } + // 원소 개수를 갱신한다 + arrSize--; + // 삭제한 원소를 반환한다 + return num; + } + + /* 리스트 용량 확장 */ + public void ExtendCapacity() { + // 길이가 arrCapacity * extendRatio인 배열을 만들고 기존 배열을 새 배열에 복사 + Array.Resize(ref arr, arrCapacity * extendRatio); + // 리스트 용량을 갱신한다 + arrCapacity = arr.Length; + } + + /* 리스트를 배열로 변환 */ + public int[] ToArray() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + int[] arr = new int[arrSize]; + for (int i = 0; i < arrSize; i++) { + arr[i] = Get(i); + } + return arr; + } + } + ``` + +=== "Go" + + ```go title="my_list.go" + /* 리스트 클래스 */ + type myList struct { + arrCapacity int + arr []int + arrSize int + extendRatio int + } + + /* 생성자 */ + func newMyList() *myList { + return &myList{ + arrCapacity: 10, // 리스트 용량 + arr: make([]int, 10), // 배열(리스트 원소 저장) + arrSize: 0, // 리스트 길이(현재 원소 개수) + extendRatio: 2, // 리스트의 용량을 확장할 때마다 적용하는 배수 + } + } + + /* 리스트 길이(현재 원소 개수) 반환 */ + func (l *myList) size() int { + return l.arrSize + } + + /* 리스트 용량 반환 */ + func (l *myList) capacity() int { + return l.arrCapacity + } + + /* 원소 접근 */ + func (l *myList) get(index int) int { + // 인덱스가 범위를 벗어나면 예외를 발생시킨다. 아래에서도 동일하다 + if index < 0 || index >= l.arrSize { + panic("인덱스가 범위를 벗어났다") + } + return l.arr[index] + } + + /* 원소 갱신 */ + func (l *myList) set(num, index int) { + if index < 0 || index >= l.arrSize { + panic("인덱스가 범위를 벗어났다") + } + l.arr[index] = num + } + + /* 맨 뒤에 원소 추가 */ + func (l *myList) add(num int) { + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if l.arrSize == l.arrCapacity { + l.extendCapacity() + } + l.arr[l.arrSize] = num + // 원소 개수를 갱신한다 + l.arrSize++ + } + + /* 중간에 원소 삽입 */ + func (l *myList) insert(num, index int) { + if index < 0 || index >= l.arrSize { + panic("인덱스가 범위를 벗어났다") + } + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if l.arrSize == l.arrCapacity { + l.extendCapacity() + } + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for j := l.arrSize - 1; j >= index; j-- { + l.arr[j+1] = l.arr[j] + } + l.arr[index] = num + // 원소 개수를 갱신한다 + l.arrSize++ + } + + /* 원소 삭제 */ + func (l *myList) remove(index int) int { + if index < 0 || index >= l.arrSize { + panic("인덱스가 범위를 벗어났다") + } + num := l.arr[index] + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for j := index; j < l.arrSize-1; j++ { + l.arr[j] = l.arr[j+1] + } + // 원소 개수를 갱신한다 + l.arrSize-- + // 삭제한 원소를 반환한다 + return num + } + + /* 리스트 용량 확장 */ + func (l *myList) extendCapacity() { + // 길이가 원래 배열의 extendRatio배인 새 배열을 생성하고, 원래 배열을 새 배열에 복사한다 + l.arr = append(l.arr, make([]int, l.arrCapacity*(l.extendRatio-1))...) + // 리스트 용량을 갱신한다 + l.arrCapacity = len(l.arr) + } + + /* 유효한 길이만큼의 리스트 반환 */ + func (l *myList) toArray() []int { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + return l.arr[:l.arrSize] + } + ``` + +=== "Swift" + + ```swift title="my_list.swift" + /* 리스트 클래스 */ + class MyList { + private var arr: [Int] // 배열(리스트 원소 저장) + private var _capacity: Int // 리스트 용량 + private var _size: Int // 리스트 길이(현재 원소 개수) + private let extendRatio: Int // 리스트의 용량을 확장할 때마다 적용하는 배수 + + /* 생성자 */ + init() { + _capacity = 10 + _size = 0 + extendRatio = 2 + arr = Array(repeating: 0, count: _capacity) + } + + /* 리스트 길이(현재 원소 개수) 반환*/ + func size() -> Int { + _size + } + + /* 리스트 용량 반환 */ + func capacity() -> Int { + _capacity + } + + /* 원소 접근 */ + func get(index: Int) -> Int { + // 인덱스가 범위를 벗어나면 오류를 발생시킨다. 아래에서도 동일하다 + if index < 0 || index >= size() { + fatalError("인덱스가 범위를 벗어났다") + } + return arr[index] + } + + /* 원소 갱신 */ + func set(index: Int, num: Int) { + if index < 0 || index >= size() { + fatalError("인덱스가 범위를 벗어났다") + } + arr[index] = num + } + + /* 맨 뒤에 원소 추가 */ + func add(num: Int) { + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if size() == capacity() { + extendCapacity() + } + arr[size()] = num + // 원소 개수를 갱신한다 + _size += 1 + } + + /* 중간에 원소 삽입 */ + func insert(index: Int, num: Int) { + if index < 0 || index >= size() { + fatalError("인덱스가 범위를 벗어났다") + } + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if size() == capacity() { + extendCapacity() + } + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for j in (index ..< size()).reversed() { + arr[j + 1] = arr[j] + } + arr[index] = num + // 원소 개수를 갱신한다 + _size += 1 + } + + /* 원소 삭제 */ + @discardableResult + func remove(index: Int) -> Int { + if index < 0 || index >= size() { + fatalError("인덱스가 범위를 벗어났다") + } + let num = arr[index] + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for j in index ..< (size() - 1) { + arr[j] = arr[j + 1] + } + // 원소 개수를 갱신한다 + _size -= 1 + // 삭제한 원소를 반환한다 + return num + } + + /* 리스트 용량 확장 */ + func extendCapacity() { + // 길이가 원래 배열의 extendRatio배인 새 배열을 생성하고, 원래 배열을 새 배열에 복사한다 + arr = arr + Array(repeating: 0, count: capacity() * (extendRatio - 1)) + // 리스트 용량을 갱신한다 + _capacity = arr.count + } + + /* 리스트를 배열로 변환 */ + func toArray() -> [Int] { + Array(arr.prefix(size())) + } + } + ``` + +=== "JS" + + ```javascript title="my_list.js" + /* 리스트 클래스 */ + class MyList { + #arr = new Array(); // 배열(리스트 원소 저장) + #capacity = 10; // 리스트 용량 + #size = 0; // 리스트 길이(현재 원소 개수) + #extendRatio = 2; // 리스트의 용량을 확장할 때마다 적용하는 배수 + + /* 생성자 */ + constructor() { + this.#arr = new Array(this.#capacity); + } + + /* 리스트 길이(현재 원소 개수) 반환*/ + size() { + return this.#size; + } + + /* 리스트 용량 반환 */ + capacity() { + return this.#capacity; + } + + /* 원소 접근 */ + get(index) { + // 인덱스가 범위를 벗어나면 예외를 발생시킨다. 아래에서도 동일하다 + if (index < 0 || index >= this.#size) throw new Error('인덱스가 범위를 벗어났다'); + return this.#arr[index]; + } + + /* 원소 갱신 */ + set(index, num) { + if (index < 0 || index >= this.#size) throw new Error('인덱스가 범위를 벗어났다'); + this.#arr[index] = num; + } + + /* 맨 뒤에 원소 추가 */ + add(num) { + // 길이가 용량과 같으면 용량을 확장해야 한다 + if (this.#size === this.#capacity) { + this.extendCapacity(); + } + // 새 원소를 리스트 맨 뒤에 추가한다 + this.#arr[this.#size] = num; + this.#size++; + } + + /* 중간에 원소 삽입 */ + insert(index, num) { + if (index < 0 || index >= this.#size) throw new Error('인덱스가 범위를 벗어났다'); + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (this.#size === this.#capacity) { + this.extendCapacity(); + } + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for (let j = this.#size - 1; j >= index; j--) { + this.#arr[j + 1] = this.#arr[j]; + } + // 원소 개수를 갱신한다 + this.#arr[index] = num; + this.#size++; + } + + /* 원소 삭제 */ + remove(index) { + if (index < 0 || index >= this.#size) throw new Error('인덱스가 범위를 벗어났다'); + let num = this.#arr[index]; + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for (let j = index; j < this.#size - 1; j++) { + this.#arr[j] = this.#arr[j + 1]; + } + // 원소 개수를 갱신한다 + this.#size--; + // 삭제한 원소를 반환한다 + return num; + } + + /* 리스트 용량 확장 */ + extendCapacity() { + // 길이가 원래 배열의 extendRatio배인 새 배열을 생성하고, 원래 배열을 새 배열에 복사한다 + this.#arr = this.#arr.concat( + new Array(this.capacity() * (this.#extendRatio - 1)) + ); + // 리스트 용량을 갱신한다 + this.#capacity = this.#arr.length; + } + + /* 리스트를 배열로 변환 */ + toArray() { + let size = this.size(); + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + const arr = new Array(size); + for (let i = 0; i < size; i++) { + arr[i] = this.get(i); + } + return arr; + } + } + ``` + +=== "TS" + + ```typescript title="my_list.ts" + /* 리스트 클래스 */ + class MyList { + private arr: Array; // 배열(리스트 원소 저장) + private _capacity: number = 10; // 리스트 용량 + private _size: number = 0; // 리스트 길이(현재 원소 개수) + private extendRatio: number = 2; // 리스트의 용량을 확장할 때마다 적용하는 배수 + + /* 생성자 */ + constructor() { + this.arr = new Array(this._capacity); + } + + /* 리스트 길이(현재 원소 개수) 반환*/ + public size(): number { + return this._size; + } + + /* 리스트 용량 반환 */ + public capacity(): number { + return this._capacity; + } + + /* 원소 접근 */ + public get(index: number): number { + // 인덱스가 범위를 벗어나면 예외를 발생시킨다. 아래에서도 동일하다 + if (index < 0 || index >= this._size) throw new Error('인덱스가 범위를 벗어났다'); + return this.arr[index]; + } + + /* 원소 갱신 */ + public set(index: number, num: number): void { + if (index < 0 || index >= this._size) throw new Error('인덱스가 범위를 벗어났다'); + this.arr[index] = num; + } + + /* 맨 뒤에 원소 추가 */ + public add(num: number): void { + // 길이가 용량과 같으면 용량을 확장해야 한다 + if (this._size === this._capacity) this.extendCapacity(); + // 새 원소를 리스트 맨 뒤에 추가한다 + this.arr[this._size] = num; + this._size++; + } + + /* 중간에 원소 삽입 */ + public insert(index: number, num: number): void { + if (index < 0 || index >= this._size) throw new Error('인덱스가 범위를 벗어났다'); + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (this._size === this._capacity) { + this.extendCapacity(); + } + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for (let j = this._size - 1; j >= index; j--) { + this.arr[j + 1] = this.arr[j]; + } + // 원소 개수를 갱신한다 + this.arr[index] = num; + this._size++; + } + + /* 원소 삭제 */ + public remove(index: number): number { + if (index < 0 || index >= this._size) throw new Error('인덱스가 범위를 벗어났다'); + let num = this.arr[index]; + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for (let j = index; j < this._size - 1; j++) { + this.arr[j] = this.arr[j + 1]; + } + // 원소 개수를 갱신한다 + this._size--; + // 삭제한 원소를 반환한다 + return num; + } + + /* 리스트 용량 확장 */ + public extendCapacity(): void { + // 길이가 size인 새 배열을 생성하고, 원래 배열을 새 배열에 복사한다 + this.arr = this.arr.concat( + new Array(this.capacity() * (this.extendRatio - 1)) + ); + // 리스트 용량을 갱신한다 + this._capacity = this.arr.length; + } + + /* 리스트를 배열로 변환 */ + public toArray(): number[] { + let size = this.size(); + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + const arr = new Array(size); + for (let i = 0; i < size; i++) { + arr[i] = this.get(i); + } + return arr; + } + } + ``` + +=== "Dart" + + ```dart title="my_list.dart" + /* 리스트 클래스 */ + class MyList { + late List _arr; // 배열(리스트 원소 저장) + int _capacity = 10; // 리스트 용량 + int _size = 0; // 리스트 길이(현재 원소 개수) + int _extendRatio = 2; // 리스트의 용량을 확장할 때마다 적용하는 배수 + + /* 생성자 */ + MyList() { + _arr = List.filled(_capacity, 0); + } + + /* 리스트 길이(현재 원소 개수) 반환*/ + int size() => _size; + + /* 리스트 용량 반환 */ + int capacity() => _capacity; + + /* 원소 접근 */ + int get(int index) { + if (index >= _size) throw RangeError('인덱스가 범위를 벗어났다'); + return _arr[index]; + } + + /* 원소 갱신 */ + void set(int index, int _num) { + if (index >= _size) throw RangeError('인덱스가 범위를 벗어났다'); + _arr[index] = _num; + } + + /* 맨 뒤에 원소 추가 */ + void add(int _num) { + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (_size == _capacity) extendCapacity(); + _arr[_size] = _num; + // 원소 개수를 갱신한다 + _size++; + } + + /* 중간에 원소 삽입 */ + void insert(int index, int _num) { + if (index >= _size) throw RangeError('인덱스가 범위를 벗어났다'); + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (_size == _capacity) extendCapacity(); + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for (var j = _size - 1; j >= index; j--) { + _arr[j + 1] = _arr[j]; + } + _arr[index] = _num; + // 원소 개수를 갱신한다 + _size++; + } + + /* 원소 삭제 */ + int remove(int index) { + if (index >= _size) throw RangeError('인덱스가 범위를 벗어났다'); + int _num = _arr[index]; + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for (var j = index; j < _size - 1; j++) { + _arr[j] = _arr[j + 1]; + } + // 원소 개수를 갱신한다 + _size--; + // 삭제한 원소를 반환한다 + return _num; + } + + /* 리스트 용량 확장 */ + void extendCapacity() { + // 길이가 원래 배열의 _extendRatio배인 새 배열을 생성한다 + final _newNums = List.filled(_capacity * _extendRatio, 0); + // 원래 배열을 새 배열에 복사한다 + List.copyRange(_newNums, 0, _arr); + // _arr의 참조를 갱신한다 + _arr = _newNums; + // 리스트 용량을 갱신한다 + _capacity = _arr.length; + } + + /* 리스트를 배열로 변환 */ + List toArray() { + List arr = []; + for (var i = 0; i < _size; i++) { + arr.add(get(i)); + } + return arr; + } + } + ``` + +=== "Rust" + + ```rust title="my_list.rs" + /* 리스트 클래스 */ + #[allow(dead_code)] + struct MyList { + arr: Vec, // 배열(리스트 원소 저장) + capacity: usize, // 리스트 용량 + size: usize, // 리스트 길이(현재 원소 개수) + extend_ratio: usize, // 리스트의 용량을 확장할 때마다 적용하는 배수 + } + + #[allow(unused, unused_comparisons)] + impl MyList { + /* 생성자 */ + pub fn new(capacity: usize) -> Self { + let mut vec = vec![0; capacity]; + Self { + arr: vec, + capacity, + size: 0, + extend_ratio: 2, + } + } + + /* 리스트 길이(현재 원소 개수) 반환*/ + pub fn size(&self) -> usize { + return self.size; + } + + /* 리스트 용량 반환 */ + pub fn capacity(&self) -> usize { + return self.capacity; + } + + /* 원소 접근 */ + pub fn get(&self, index: usize) -> i32 { + // 인덱스가 범위를 벗어나면 예외를 발생시킨다. 아래에서도 동일하다 + if index >= self.size { + panic!("인덱스가 범위를 벗어났다") + }; + return self.arr[index]; + } + + /* 원소 갱신 */ + pub fn set(&mut self, index: usize, num: i32) { + if index >= self.size { + panic!("인덱스가 범위를 벗어났다") + }; + self.arr[index] = num; + } + + /* 맨 뒤에 원소 추가 */ + pub fn add(&mut self, num: i32) { + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if self.size == self.capacity() { + self.extend_capacity(); + } + self.arr[self.size] = num; + // 원소 개수를 갱신한다 + self.size += 1; + } + + /* 중간에 원소 삽입 */ + pub fn insert(&mut self, index: usize, num: i32) { + if index >= self.size() { + panic!("인덱스가 범위를 벗어났다") + }; + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if self.size == self.capacity() { + self.extend_capacity(); + } + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for j in (index..self.size).rev() { + self.arr[j + 1] = self.arr[j]; + } + self.arr[index] = num; + // 원소 개수를 갱신한다 + self.size += 1; + } + + /* 원소 삭제 */ + pub fn remove(&mut self, index: usize) -> i32 { + if index >= self.size() { + panic!("인덱스가 범위를 벗어났다") + }; + let num = self.arr[index]; + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for j in index..self.size - 1 { + self.arr[j] = self.arr[j + 1]; + } + // 원소 개수를 갱신한다 + self.size -= 1; + // 삭제한 원소를 반환한다 + return num; + } + + /* 리스트 용량 확장 */ + pub fn extend_capacity(&mut self) { + // 길이가 원래 배열의 extend_ratio배인 새 배열을 생성하고, 원래 배열을 새 배열에 복사한다 + let new_capacity = self.capacity * self.extend_ratio; + self.arr.resize(new_capacity, 0); + // 리스트 용량을 갱신한다 + self.capacity = new_capacity; + } + + /* 리스트를 배열로 변환 */ + pub fn to_array(&self) -> Vec { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + let mut arr = Vec::new(); + for i in 0..self.size { + arr.push(self.get(i)); + } + arr + } + } + ``` + +=== "C" + + ```c title="my_list.c" + /* 리스트 클래스 */ + typedef struct { + int *arr; // 배열(리스트 원소 저장) + int capacity; // 리스트 용량 + int size; // 리스트 크기 + int extendRatio; // 리스트 용량을 확장할 때마다 적용하는 배수 + } MyList; + + /* 생성자 */ + MyList *newMyList() { + MyList *nums = malloc(sizeof(MyList)); + nums->capacity = 10; + nums->arr = malloc(sizeof(int) * nums->capacity); + nums->size = 0; + nums->extendRatio = 2; + return nums; + } + + /* 소멸자 */ + void delMyList(MyList *nums) { + free(nums->arr); + free(nums); + } + + /* 리스트 길이 구하기 */ + int size(MyList *nums) { + return nums->size; + } + + /* 리스트 용량 반환 */ + int capacity(MyList *nums) { + return nums->capacity; + } + + /* 원소 접근 */ + int get(MyList *nums, int index) { + assert(index >= 0 && index < nums->size); + return nums->arr[index]; + } + + /* 원소 갱신 */ + void set(MyList *nums, int index, int num) { + assert(index >= 0 && index < nums->size); + nums->arr[index] = num; + } + + /* 맨 뒤에 원소 추가 */ + void add(MyList *nums, int num) { + if (size(nums) == capacity(nums)) { + extendCapacity(nums); // 용량 확장 + } + nums->arr[size(nums)] = num; + nums->size++; + } + + /* 중간에 원소 삽입 */ + void insert(MyList *nums, int index, int num) { + assert(index >= 0 && index < size(nums)); + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (size(nums) == capacity(nums)) { + extendCapacity(nums); // 용량 확장 + } + for (int i = size(nums); i > index; --i) { + nums->arr[i] = nums->arr[i - 1]; + } + nums->arr[index] = num; + nums->size++; + } + + /* 원소 삭제 */ + // 주의: stdio.h가 remove라는 이름을 사용한다 + int removeItem(MyList *nums, int index) { + assert(index >= 0 && index < size(nums)); + int num = nums->arr[index]; + for (int i = index; i < size(nums) - 1; i++) { + nums->arr[i] = nums->arr[i + 1]; + } + nums->size--; + return num; + } + + /* 리스트 용량 확장 */ + void extendCapacity(MyList *nums) { + // 먼저 공간 할당 + int newCapacity = capacity(nums) * nums->extendRatio; + int *extend = (int *)malloc(sizeof(int) * newCapacity); + int *temp = nums->arr; + + // 기존 데이터를 새 공간에 복사 + for (int i = 0; i < size(nums); i++) + extend[i] = nums->arr[i]; + + // 기존 데이터 공간 해제 + free(temp); + + // 새 데이터로 갱신 + nums->arr = extend; + nums->capacity = newCapacity; + } + + /* 출력을 위해 리스트를 Array로 변환 */ + int *toArray(MyList *nums) { + return nums->arr; + } + ``` + +=== "Kotlin" + + ```kotlin title="my_list.kt" + /* 리스트 클래스 */ + class MyList { + private var arr: IntArray = intArrayOf() // 배열(리스트 원소 저장) + private var capacity: Int = 10 // 리스트 용량 + private var size: Int = 0 // 리스트 길이(현재 원소 개수) + private var extendRatio: Int = 2 // 리스트의 용량을 확장할 때마다 적용하는 배수 + + /* 생성자 */ + init { + arr = IntArray(capacity) + } + + /* 리스트 길이(현재 원소 개수) 반환 */ + fun size(): Int { + return size + } + + /* 리스트 용량 반환 */ + fun capacity(): Int { + return capacity + } + + /* 원소 접근 */ + fun get(index: Int): Int { + // 인덱스가 범위를 벗어나면 예외를 발생시킨다. 아래에서도 동일하다 + if (index < 0 || index >= size) + throw IndexOutOfBoundsException("인덱스가 범위를 벗어났다") + return arr[index] + } + + /* 원소 갱신 */ + fun set(index: Int, num: Int) { + if (index < 0 || index >= size) + throw IndexOutOfBoundsException("인덱스가 범위를 벗어났다") + arr[index] = num + } + + /* 맨 뒤에 원소 추가 */ + fun add(num: Int) { + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (size == capacity()) + extendCapacity() + arr[size] = num + // 원소 개수를 갱신한다 + size++ + } + + /* 중간에 원소 삽입 */ + fun insert(index: Int, num: Int) { + if (index < 0 || index >= size) + throw IndexOutOfBoundsException("인덱스가 범위를 벗어났다") + // 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + if (size == capacity()) + extendCapacity() + // 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for (j in size - 1 downTo index) + arr[j + 1] = arr[j] + arr[index] = num + // 원소 개수를 갱신한다 + size++ + } + + /* 원소 삭제 */ + fun remove(index: Int): Int { + if (index < 0 || index >= size) + throw IndexOutOfBoundsException("인덱스가 범위를 벗어났다") + val num = arr[index] + // 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for (j in index..= size + @arr[index] + end + + ### 원소 접근 ### + def set(index, num) + raise IndexError, "인덱스가 범위를 벗어났다" if index < 0 || index >= size + @arr[index] = num + end + + ### 맨 뒤에 원소 추가 ### + def add(num) + # 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + extend_capacity if size == capacity + @arr[size] = num + + # 원소 개수를 갱신한다 + @size += 1 + end + + ### 중간에 원소 삽입 ### + def insert(index, num) + raise IndexError, "인덱스가 범위를 벗어났다" if index < 0 || index >= size + + # 원소 개수가 용량을 초과하면 용량 확장을 수행한다 + extend_capacity if size == capacity + + # 인덱스 index와 그 뒤의 원소를 모두 한 칸씩 뒤로 이동한다 + for j in (size - 1).downto(index) + @arr[j + 1] = @arr[j] + end + @arr[index] = num + + # 원소 개수를 갱신한다 + @size += 1 + end + + ### 원소 삭제 ### + def remove(index) + raise IndexError, "인덱스가 범위를 벗어났다" if index < 0 || index >= size + num = @arr[index] + + # 인덱스 index 뒤의 원소를 모두 한 칸씩 앞으로 이동한다 + for j in index...size + @arr[j] = @arr[j + 1] + end + + # 원소 개수를 갱신한다 + @size -= 1 + + # 삭제한 원소를 반환한다 + num + end + + ### 리스트 용량 확장 ### + def extend_capacity + # 길이가 원래 배열의 extend_ratio배인 새 배열을 생성하고, 원래 배열을 새 배열에 복사한다 + arr = @arr.dup + Array.new(capacity * (@extend_ratio - 1)) + # 리스트 용량을 갱신한다 + @capacity = arr.length + end + + ### 리스트를 배열로 변환 ### + def to_array + sz = size + # 유효한 길이 범위 안의 리스트 원소만 변환한다 + arr = Array.new(sz) + for i in 0...sz + arr[i] = get(i) + end + arr + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ diff --git a/ko/docs/chapter_array_and_linkedlist/ram_and_cache.md b/ko/docs/chapter_array_and_linkedlist/ram_and_cache.md new file mode 100644 index 000000000..66b356d2e --- /dev/null +++ b/ko/docs/chapter_array_and_linkedlist/ram_and_cache.md @@ -0,0 +1,83 @@ +--- +comments: true +--- + +# 4.4   메모리와 캐시 * + +이 장의 앞선 두 절에서는 기본적이고 중요한 두 자료 구조인 배열과 연결 리스트를 살펴보았다. 이들은 각각 ‘연속 저장’과 ‘분산 저장’이라는 물리적 구조를 대표한다. + +실제로 **물리적 구조는 프로그램의 메모리 및 캐시 사용 효율을 크게 좌우하며**, 나아가 알고리즘 프로그램의 전체 성능에 영향을 준다. + +## 4.4.1   컴퓨터의 저장 장치 + +컴퓨터에는 하드 디스크(hard disk), 메모리(random-access memory, RAM), 캐시(cache memory)라는 세 종류의 저장 장치가 있다. 아래 표(4-2)는 컴퓨터 시스템에서 각 장치가 맡는 역할과 성능 특징을 보여 준다. + +

표 4-2   컴퓨터의 저장 장치

+ +
+ +| | 하드 디스크 | 메모리 | 캐시 | +| -------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------- | +| 용도 | 운영체제, 프로그램, 파일 등의 데이터를 장기간 저장 | 실행 중인 프로그램과 처리 중인 데이터를 임시 저장 | 자주 접근하는 데이터와 명령어를 저장하여 CPU의 메모리 접근 횟수를 줄임 | +| 휘발성 | 전원이 꺼져도 데이터가 사라지지 않음 | 전원이 꺼지면 데이터가 사라짐 | 전원이 꺼지면 데이터가 사라짐 | +| 용량 | 큼, TB 단위 | 작음, GB 단위 | 매우 작음, MB 단위 | +| 속도 | 느림, 수백~수천 MB/s | 빠름, 수십 GB/s | 매우 빠름, 수십~수백 GB/s | +| 가격(중국 위안) | 저렴함, GB당 수십 펀~수 위안 | 비쌈, GB당 수십~수백 위안 | 매우 비쌈, CPU 가격에 포함 | + +
+ +컴퓨터의 저장 시스템은 아래 그림(4-9)과 같은 피라미드 구조로 생각할 수 있다. 꼭대기에 가까울수록 속도는 빠르고, 용량은 작으며, 비용은 높다. 이러한 계층적 설계는 우연이 아니라 컴퓨터 과학자와 엔지니어들이 깊이 고민한 결과다. + +- **메모리로 하드 디스크를 대체하기는 어렵다**. 우선 메모리의 데이터는 전원이 꺼지면 사라지므로 장기 저장에 적합하지 않다. 또한 메모리 가격은 하드 디스크의 수십 배여서 소비자 시장에 보편적으로 적용하기 어렵다. +- **캐시에서 큰 용량과 빠른 속도를 동시에 얻기는 어렵다**. L1, L2, L3 캐시의 용량이 커질수록 물리적 크기도 커지고 CPU 코어와의 거리도 멀어진다. 그 결과 데이터 전송 시간이 늘고 원소 접근 지연도 커진다. 현재 기술에서 다단계 캐시 구조는 용량, 속도, 비용 사이의 최적 절충점이다. + +![컴퓨터 저장 시스템](ram_and_cache.assets/storage_pyramid.png){ class="animation-figure" } + +

그림 4-9   컴퓨터 저장 시스템

+ +!!! tip "참고" + + 컴퓨터의 저장 계층 구조는 속도, 용량, 비용 사이의 정교한 균형을 보여 준다. 이러한 절충은 모든 산업 분야에 널리 존재하며, 서로 다른 장점과 제약 사이에서 최적의 균형을 찾아야 한다. + +전체적으로 **하드 디스크는 많은 데이터를 장기간 저장하고, 메모리는 실행 중인 프로그램이 처리하는 데이터를 임시 저장하며, 캐시는 자주 접근하는 데이터와 명령어를 저장하여** 실행 효율을 높인다. 세 장치는 서로 협력하여 컴퓨터 시스템이 효율적으로 작동하게 한다. + +아래 그림(4-10)과 같이 프로그램이 실행되면 하드 디스크의 데이터를 메모리로 읽어 와 CPU가 계산에 사용한다. 캐시는 CPU의 일부로 볼 수 있으며, **메모리에서 데이터를 지능적으로 불러와** CPU에 빠른 데이터 읽기를 제공한다. 이를 통해 실행 효율을 크게 높이고 더 느린 메모리에 대한 의존을 줄인다. + +![하드 디스크, 메모리, 캐시 사이의 데이터 흐름](ram_and_cache.assets/computer_storage_devices.png){ class="animation-figure" } + +

그림 4-10   하드 디스크, 메모리, 캐시 사이의 데이터 흐름

+ +## 4.4.2   자료 구조의 메모리 효율 + +메모리 공간 활용 측면에서 배열과 연결 리스트에는 각각 장점과 한계가 있다. + +한편으로 **메모리는 한정되어 있으며 같은 메모리 영역을 여러 프로그램이 공유할 수 없으므로**, 자료 구조가 공간을 최대한 효율적으로 사용하기를 바란다. 배열 원소는 빈틈없이 붙어 있어 연결 리스트 노드 사이의 참조(포인터)를 저장할 추가 공간이 필요 없으므로 공간 효율이 더 높다. 하지만 배열은 충분한 연속 메모리를 한꺼번에 할당해야 하므로 공간 낭비가 발생할 수 있으며, 용량 확장에도 시간과 공간이 추가로 든다. 반면 연결 리스트는 ‘노드’ 단위로 메모리를 동적으로 할당하고 회수하므로 더 유연하다. + +다른 한편으로 프로그램이 실행되면서 **메모리 할당과 해제가 반복되면 빈 메모리의 단편화가 점점 심해져** 메모리 활용 효율이 낮아진다. 배열은 연속 저장 방식을 사용하므로 상대적으로 단편화를 덜 일으킨다. 반면 연결 리스트의 원소는 분산되어 있으므로 삽입과 삭제가 잦으면 단편화가 더 쉽게 발생한다. + +## 4.4.3   자료 구조의 캐시 효율 + +캐시는 메모리보다 용량은 훨씬 작지만 속도는 훨씬 빨라 프로그램 실행 속도에 매우 중요한 역할을 한다. 용량이 제한되어 있어 자주 사용하는 데이터 일부만 저장할 수 있으므로, CPU가 접근하려는 데이터가 캐시에 없으면 캐시 미스(cache miss)가 발생한다. 이때 CPU는 더 느린 메모리에서 필요한 데이터를 불러와야 한다. + +따라서 **‘캐시 미스’가 적을수록 CPU의 데이터 읽기 및 쓰기 효율이 높아지고**, 프로그램 성능도 좋아진다. CPU가 캐시에서 데이터를 성공적으로 가져오는 비율을 캐시 적중률(cache hit rate)이라고 하며, 캐시 효율을 측정하는 지표로 자주 사용한다. + +캐시는 효율을 최대한 높이기 위해 다음과 같은 데이터 로딩 방식을 사용한다. + +- **캐시 라인**: 캐시는 데이터를 한 바이트씩 저장하고 불러오는 대신 캐시 라인 단위로 처리한다. 캐시 라인 단위 전송은 바이트 단위 전송보다 효율적이다. +- **프리페치**: 프로세서는 순차 접근이나 고정 간격 접근 같은 데이터 접근 패턴을 예측하고, 그 패턴에 따라 데이터를 미리 캐시에 불러와 적중률을 높인다. +- **공간 지역성**: 어떤 데이터에 접근하면 가까운 시일 안에 주변 데이터에도 접근할 가능성이 있다. 따라서 캐시는 데이터를 불러올 때 주변 데이터도 함께 불러와 적중률을 높인다. +- **시간 지역성**: 어떤 데이터에 접근하면 머지않아 다시 접근할 가능성이 높다. 캐시는 이 원리를 이용해 최근 접근한 데이터를 유지함으로써 적중률을 높인다. + +실제로 **배열과 연결 리스트의 캐시 활용 효율은 서로 다르며**, 주로 다음과 같은 차이가 있다. + +- **공간 사용량**: 연결 리스트 원소는 배열 원소보다 더 많은 공간을 차지하므로 캐시에 담을 수 있는 유효 데이터의 양이 적다. +- **캐시 라인**: 연결 리스트의 데이터는 메모리 곳곳에 흩어져 있지만 캐시는 ‘라인 단위’로 불러오므로, 불필요한 데이터를 가져오는 비율이 높다. +- **프리페치**: 배열의 데이터 접근 패턴은 연결 리스트보다 ‘예측 가능성’이 높다. 즉, 시스템이 다음에 불러올 데이터를 더 쉽게 예상할 수 있다. +- **공간 지역성**: 배열은 한곳에 모인 메모리 공간에 저장되므로, 불러온 데이터 주변의 데이터에도 곧 접근할 가능성이 더 높다. + +대체로 **배열은 캐시 적중률이 더 높아 연산 효율에서도 연결 리스트보다 유리한 경우가 많다**. 이 때문에 알고리즘 문제를 풀 때는 배열 기반 자료 구조를 더 선호하는 경우가 많다. + +다만 **캐시 효율이 높다고 해서 모든 상황에서 배열이 연결 리스트보다 좋은 것은 아니다**. 실제로 어떤 자료 구조를 쓸지는 구체적인 요구에 따라 결정해야 한다. 예를 들어 배열과 연결 리스트 모두 ‘스택’을 구현할 수 있지만(다음 장에서 자세히 설명한다), 적합한 상황은 다르다. + +- 알고리즘 문제를 풀 때는 배열 기반 스택을 선호한다. 배열에 일정한 메모리 공간을 미리 할당하기만 하면, 높은 연산 효율과 임의 접근 기능을 얻을 수 있기 때문이다. +- 데이터가 매우 많고 변화가 잦으며 스택의 예상 크기를 가늠하기 어렵다면 연결 리스트 기반 스택이 더 적합하다. 연결 리스트는 많은 데이터를 메모리 여러 곳에 분산 저장할 수 있으며, 배열 용량 확장에 따른 추가 비용도 피할 수 있다. diff --git a/ko/docs/chapter_array_and_linkedlist/summary.md b/ko/docs/chapter_array_and_linkedlist/summary.md new file mode 100644 index 000000000..b8039325c --- /dev/null +++ b/ko/docs/chapter_array_and_linkedlist/summary.md @@ -0,0 +1,90 @@ +--- +comments: true +--- + +# 4.5   요약 + +### 1.   핵심 정리 + +- 배열과 연결 리스트는 두 가지 기본 자료 구조로, 컴퓨터 메모리에 데이터를 저장하는 연속 공간 저장과 분산 공간 저장을 각각 대표한다. 둘의 특징은 서로 보완적이다. +- 배열은 임의 접근을 지원하고 메모리 사용량이 적다. 그러나 원소 삽입과 삭제의 효율이 낮으며 초기화한 뒤 길이를 바꿀 수 없다. +- 연결 리스트는 참조(포인터)를 바꾸어 노드를 효율적으로 삽입하고 삭제하며, 길이도 유연하게 조절할 수 있다. 하지만 노드 접근 효율이 낮고 메모리를 더 많이 사용한다. 대표적인 종류에는 단일 연결 리스트, 원형 연결 리스트, 이중 연결 리스트가 있다. +- 리스트는 삽입, 삭제, 탐색, 갱신을 지원하는 순서 있는 원소의 모음으로, 보통 동적 배열로 구현한다. 배열의 장점을 유지하면서 길이를 유연하게 조절할 수 있다. +- 리스트의 등장으로 배열의 실용성이 크게 높아졌지만, 메모리 공간 일부가 낭비될 수 있다. +- 프로그램이 실행될 때 데이터는 주로 메모리에 저장된다. 배열은 메모리 공간 효율이 더 높고, 연결 리스트는 메모리를 더 유연하게 사용한다. +- 캐시는 캐시 라인, 프리페치, 공간 지역성, 시간 지역성 등의 로딩 방식을 통해 CPU에 빠른 데이터 접근을 제공하여 프로그램 실행 효율을 크게 높인다. +- 배열은 캐시 적중률이 더 높으므로 보통 연결 리스트보다 효율적이다. 자료 구조를 선택할 때는 구체적인 요구와 상황에 맞춰야 한다. + +### 2.   Q & A + +**Q**: 배열을 스택에 저장하는지 힙에 저장하는지에 따라 시간 효율과 공간 효율이 달라지는가? + +스택에 저장한 배열도 힙에 저장한 배열도 연속된 메모리 공간에 있으므로, 데이터 연산 효율은 대체로 같다. 다만 스택과 힙의 특성 때문에 다음과 같은 차이가 생긴다. + +1. 할당 및 해제 효율: 스택은 비교적 작은 메모리 영역으로 컴파일러가 자동으로 할당한다. 힙은 더 크며 코드에서 동적으로 할당할 수 있고 단편화도 더 쉽게 발생한다. 따라서 힙의 할당 및 해제 연산은 보통 스택보다 느리다. +2. 크기 제한: 스택 메모리는 비교적 작고 힙의 크기는 대개 사용 가능한 메모리에 의해 제한된다. 따라서 큰 배열을 저장하기에는 힙이 더 적합하다. +3. 유연성: 스택에 저장하는 배열의 크기는 컴파일 시 정해야 하지만, 힙에 저장하는 배열의 크기는 실행 중에 동적으로 정할 수 있다. + +**Q**: 배열은 원소의 자료형이 같아야 한다고 하는데, 연결 리스트에서는 왜 이를 강조하지 않는가? + +연결 리스트는 참조(포인터)로 연결된 노드들로 이루어진다. 각 노드에는 `int`, `double`, `string`, `object` 등 서로 다른 자료형의 데이터를 저장할 수 있다. + +반면 배열 원소는 자료형이 같아야 오프셋을 계산하여 해당 원소의 위치를 구할 수 있다. 예를 들어 배열에 `int`와 `long`이 함께 있으면 원소 하나가 각각 4바이트와 8바이트를 차지한다. ‘원소 길이’가 두 가지이므로 다음 식으로 오프셋을 계산할 수 없다. + +```shell +# 원소 메모리 주소 = 배열 메모리 주소(첫 원소의 메모리 주소) + 원소 길이 * 원소 인덱스 +``` + +**Q**: 노드 `P`를 삭제한 뒤 `P.next`를 `None`으로 설정해야 하는가? + +`P.next`를 바꾸지 않아도 된다. 해당 연결 리스트에서는 머리 노드부터 꼬리 노드까지 순회해도 더 이상 `P`를 만나지 않는다. 즉, `P`는 이미 연결 리스트에서 삭제되었으므로 어디를 가리키든 이 연결 리스트에 영향을 주지 않는다. + +자료 구조와 알고리즘 문제를 푸는 관점에서는 프로그램 논리가 올바르기만 하면 연결을 끊지 않아도 된다. 표준 라이브러리 관점에서는 연결을 끊는 편이 더 안전하고 논리도 명확하다. 연결을 끊지 않은 상태에서 삭제한 노드가 정상적으로 회수되지 않으면, 뒤따르는 노드의 메모리 회수에도 영향을 줄 수 있다. + +**Q**: 연결 리스트의 삽입과 삭제는 시간 복잡도가 $O(1)$이라고 한다. 하지만 먼저 원소를 찾는 데 $O(n)$이 필요한데, 전체 시간 복잡도는 왜 $O(n)$이 아닌가? + +원소를 먼저 찾고 삭제한다면 시간 복잡도는 실제로 $O(n)$이다. 다만 연결 리스트의 $O(1)$ 삽입 및 삭제는 다른 응용에서 장점을 발휘한다. 예를 들어 덱은 연결 리스트로 구현하기에 적합하다. 머리 노드와 꼬리 노드를 항상 가리키는 포인터 변수를 유지하면 매번 삽입과 삭제를 $O(1)$에 수행할 수 있다. + +**Q**: ‘연결 리스트의 정의와 저장 방식’ 그림에서 노드 포인터를 저장하는 연한 파란색 부분은 하나의 메모리 주소를 차지하는가? 아니면 노드 값과 반씩 차지하는가? + +그 그림은 정성적인 설명이다. 정확한 크기는 구체적인 상황에 따라 분석해야 한다. + +- 노드 값이 차지하는 공간은 자료형에 따라 다르다. 예를 들어 `int`, `long`, `double`, 인스턴스 객체 등이 있다. +- 포인터 변수가 차지하는 메모리 크기는 운영체제와 컴파일 환경에 따라 정해지며, 대개 8바이트 또는 4바이트다. + +**Q**: 리스트 끝에 원소를 추가하는 연산은 항상 $O(1)$인가? + +원소를 추가할 때 리스트의 길이를 넘으면 먼저 용량을 늘려야 한다. 시스템이 새 메모리 영역을 요청하고 기존 원소 전체를 옮기므로, 이때의 시간 복잡도는 $O(n)$이다. + +**Q**: ‘리스트는 배열의 실용성을 크게 높였지만 메모리 공간 일부를 낭비할 수 있다’는 설명에서, 공간 낭비는 용량, 길이, 확장 배수 같은 추가 변수의 메모리를 뜻하는가? + +여기서 공간 낭비는 주로 두 가지를 뜻한다. 하나는 리스트에 초기 길이를 설정하지만 그만큼 필요하지 않을 수 있다는 것이다. 다른 하나는 잦은 확장을 피하려고 보통 $\times 1.5$처럼 일정한 계수를 곱해 용량을 늘린다는 것이다. 이때도 빈자리가 많이 생기며, 보통 이를 전부 채우지는 못한다. + +**Q**: Python에서 `n = [1, 2, 3]`을 초기화하면 세 원소의 주소가 이어져 있다. 하지만 `m = [2, 1, 3]`을 초기화하면 원소들의 id가 연속적이지 않고 `n`의 같은 값과 각각 일치한다. 주소가 연속적이지 않아도 `m`은 배열인가? + +리스트 원소를 연결 리스트 노드로 바꾸어 `n = [n1, n2, n3, n4, n5]`라고 하면, 이 다섯 노드 객체도 보통 메모리 곳곳에 흩어져 있다. 그래도 리스트 인덱스가 주어지면 $O(1)$ 시간에 노드의 메모리 주소를 얻어 해당 노드에 접근할 수 있다. 배열에 저장된 것은 노드 자체가 아니라 노드의 참조이기 때문이다. + +많은 언어와 달리 Python에서는 숫자도 객체로 감싸져 있으며, 리스트에는 숫자 자체가 아닌 숫자의 참조가 저장된다. 따라서 두 배열의 같은 숫자가 같은 id를 가지며, 숫자들의 메모리 주소는 연속적일 필요가 없다. + +**Q**: C++ STL의 `std::list`는 이중 연결 리스트를 구현하는데, 일부 알고리즘 책에서는 거의 직접 쓰지 않는 것 같다. 어떤 한계가 있기 때문인가? + +한편으로는 보통 배열로 알고리즘을 구현하는 것을 선호하며, 꼭 필요할 때만 연결 리스트를 사용한다. 주된 이유는 두 가지다. + +- 공간 비용: 원소마다 이전 원소와 다음 원소를 위한 포인터가 두 개 더 필요하므로 `std::list`는 보통 `std::vector`보다 공간을 많이 차지한다. +- 캐시에 불리함: 데이터가 연속적으로 저장되지 않아 `std::list`의 캐시 활용률이 낮다. 일반적으로는 `std::vector`의 성능이 더 좋다. + +다른 한편으로 연결 리스트가 필요한 경우는 주로 이진 트리와 그래프다. 스택과 큐는 연결 리스트 대신 프로그래밍 언어가 제공하는 `stack`과 `queue`를 쓰는 경우가 많다. + +**Q**: `res = [[0]] * n`으로 만든 이차원 리스트에서 각 `[0]`은 서로 독립적인가? + +독립적이지 않다. 이 이차원 리스트의 모든 `[0]`은 실제로 같은 객체를 참조한다. 한 원소를 수정하면 대응하는 원소가 모두 함께 바뀐다. + +각 `[0]`을 독립적으로 만들려면 `res = [[0] for _ in range(n)]`을 사용하면 된다. 이 방식은 서로 독립적인 `[0]` 리스트 객체 $n$개를 초기화한다. + +**Q**: `res = [0] * n`으로 만든 리스트에서 각 정수 0은 서로 독립적인가? + +이 리스트의 모든 정수 0은 같은 객체를 참조한다. Python은 객체 재사용을 극대화하고 성능을 높이기 위해 작은 정수(보통 -5부터 256까지)를 캐시 풀로 관리하기 때문이다. + +모두 같은 객체를 가리켜도 리스트의 각 원소는 독립적으로 바꿀 수 있다. Python의 정수는 ‘불변 객체’이기 때문이다. 원소를 바꾸면 기존 객체 자체가 바뀌는 것이 아니라 다른 객체를 참조하게 된다. + +그러나 리스트 원소가 리스트, 딕셔너리, 클래스 인스턴스 같은 ‘가변 객체’라면, 원소의 내용을 수정할 때 객체 자체가 바뀌므로 이를 참조하는 모든 원소에서 같은 변화가 나타난다. diff --git a/ko/docs/chapter_backtracking/backtracking_algorithm.md b/ko/docs/chapter_backtracking/backtracking_algorithm.md new file mode 100644 index 000000000..25abd2bbe --- /dev/null +++ b/ko/docs/chapter_backtracking/backtracking_algorithm.md @@ -0,0 +1,2003 @@ +--- +comments: true +--- + +# 13.1   백트래킹 알고리즘 + +백트래킹 알고리즘(backtracking algorithm)은 모든 경우를 열거하여 문제를 푸는 방법이다. 초기 상태에서 출발하여 가능한 모든 해결 방법을 완전 탐색하고, 올바른 해를 만나면 기록한다. 해를 찾거나 가능한 선택을 모두 시도해도 찾을 수 없을 때까지 진행하는 것이 핵심이다. + +백트래킹은 보통 '깊이 우선 탐색'으로 해 공간을 순회한다. '이진 트리' 장에서 전위, 중위, 후위 순회가 모두 깊이 우선 탐색이라고 설명했다. 이제 전위 순회를 이용한 백트래킹 문제를 만들어 작동 원리를 차근차근 알아보자. + +!!! question "예제 1" + + 이진 트리가 주어졌을 때 값이 $7$인 모든 노드를 찾아 기록하고, 노드 리스트를 반환하라. + +이 문제에서는 트리를 전위 순회하며 현재 노드의 값이 $7$인지 확인하고, 맞으면 해당 노드의 값을 결과 리스트 `res`에 추가한다. 과정은 아래 그림(13-1)과 코드에 나타나 있다. + +=== "Python" + + ```python title="preorder_traversal_i_compact.py" + def pre_order(root: TreeNode): + """전위 순회: 예제 1""" + if root is None: + return + if root.val == 7: + # 해를 기록한다 + res.append(root) + pre_order(root.left) + pre_order(root.right) + ``` + +=== "C++" + + ```cpp title="preorder_traversal_i_compact.cpp" + /* 전위 순회: 예제 1 */ + void preOrder(TreeNode *root) { + if (root == nullptr) { + return; + } + if (root->val == 7) { + // 해를 기록한다 + res.push_back(root); + } + preOrder(root->left); + preOrder(root->right); + } + ``` + +=== "Java" + + ```java title="preorder_traversal_i_compact.java" + /* 전위 순회: 예제 1 */ + void preOrder(TreeNode root) { + if (root == null) { + return; + } + if (root.val == 7) { + // 해를 기록한다 + res.add(root); + } + preOrder(root.left); + preOrder(root.right); + } + ``` + +=== "C#" + + ```csharp title="preorder_traversal_i_compact.cs" + /* 전위 순회: 예제 1 */ + void PreOrder(TreeNode? root) { + if (root == null) { + return; + } + if (root.val == 7) { + // 해를 기록한다 + res.Add(root); + } + PreOrder(root.left); + PreOrder(root.right); + } + ``` + +=== "Go" + + ```go title="preorder_traversal_i_compact.go" + /* 전위 순회: 예제 1 */ + func preOrderI(root *TreeNode, res *[]*TreeNode) { + if root == nil { + return + } + if (root.Val).(int) == 7 { + // 해를 기록한다 + *res = append(*res, root) + } + preOrderI(root.Left, res) + preOrderI(root.Right, res) + } + ``` + +=== "Swift" + + ```swift title="preorder_traversal_i_compact.swift" + /* 전위 순회: 예제 1 */ + func preOrder(root: TreeNode?) { + guard let root = root else { + return + } + if root.val == 7 { + // 해를 기록한다 + res.append(root) + } + preOrder(root: root.left) + preOrder(root: root.right) + } + ``` + +=== "JS" + + ```javascript title="preorder_traversal_i_compact.js" + /* 전위 순회: 예제 1 */ + function preOrder(root, res) { + if (root === null) { + return; + } + if (root.val === 7) { + // 해를 기록한다 + res.push(root); + } + preOrder(root.left, res); + preOrder(root.right, res); + } + ``` + +=== "TS" + + ```typescript title="preorder_traversal_i_compact.ts" + /* 전위 순회: 예제 1 */ + function preOrder(root: TreeNode | null, res: TreeNode[]): void { + if (root === null) { + return; + } + if (root.val === 7) { + // 해를 기록한다 + res.push(root); + } + preOrder(root.left, res); + preOrder(root.right, res); + } + ``` + +=== "Dart" + + ```dart title="preorder_traversal_i_compact.dart" + /* 전위 순회: 예제 1 */ + void preOrder(TreeNode? root, List res) { + if (root == null) { + return; + } + if (root.val == 7) { + // 해를 기록한다 + res.add(root); + } + preOrder(root.left, res); + preOrder(root.right, res); + } + ``` + +=== "Rust" + + ```rust title="preorder_traversal_i_compact.rs" + /* 전위 순회: 예제 1 */ + fn pre_order(res: &mut Vec>>, root: Option<&Rc>>) { + if root.is_none() { + return; + } + if let Some(node) = root { + if node.borrow().val == 7 { + // 해를 기록한다 + res.push(node.clone()); + } + pre_order(res, node.borrow().left.as_ref()); + pre_order(res, node.borrow().right.as_ref()); + } + } + ``` + +=== "C" + + ```c title="preorder_traversal_i_compact.c" + /* 전위 순회: 예제 1 */ + void preOrder(TreeNode *root) { + if (root == NULL) { + return; + } + if (root->val == 7) { + // 해를 기록한다 + res[resSize++] = root; + } + preOrder(root->left); + preOrder(root->right); + } + ``` + +=== "Kotlin" + + ```kotlin title="preorder_traversal_i_compact.kt" + /* 전위 순회: 예제 1 */ + fun preOrder(root: TreeNode?) { + if (root == null) { + return + } + if (root._val == 7) { + // 해를 기록한다 + res!!.add(root) + } + preOrder(root.left) + preOrder(root.right) + } + ``` + +=== "Ruby" + + ```ruby title="preorder_traversal_i_compact.rb" + ### 전위 순회: 예제 1 ### + def pre_order(root) + return unless root + + # 해를 기록한다 + $res << root if root.val == 7 + + pre_order(root.left) + pre_order(root.right) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +![전위 순회로 노드 탐색](backtracking_algorithm.assets/preorder_find_nodes.png){ class="animation-figure" } + +

그림 13-1   전위 순회로 노드 탐색

+ +## 13.1.1   시도와 되돌리기 + +**백트래킹이라고 부르는 이유는 해 공간을 탐색할 때 '시도'와 '되돌리기' 전략을 사용하기 때문이다**. 탐색 중 더 진행할 수 없거나 조건을 만족하는 해를 얻을 수 없는 상태를 만나면, 직전 선택을 취소하고 이전 상태로 돌아가 다른 선택을 시도한다. + +예제 1에서는 각 노드의 방문이 한 번의 '시도'이며, 리프 노드를 지나거나 부모 노드로 돌아가는 `return`이 '되돌리기'에 해당한다. + +**되돌리기에는 함수 반환만 포함되는 것이 아니다**. 이를 설명하기 위해 예제 1을 조금 확장해 보자. + +!!! question "예제 2" + + 이진 트리에서 값이 $7$인 모든 노드를 찾아 **루트 노드에서 각 노드까지의 경로를 반환하라**. + +예제 1의 코드에 리스트 `path`를 추가하여 방문한 노드의 경로를 기록해야 한다. 값이 $7$인 노드를 방문하면 `path`를 복사하여 결과 리스트 `res`에 추가한다. 순회를 마치면 `res`에 모든 해가 저장된다. 코드는 다음과 같다. + +=== "Python" + + ```python title="preorder_traversal_ii_compact.py" + def pre_order(root: TreeNode): + """전위 순회: 예제 2""" + if root is None: + return + # 시도 + path.append(root) + if root.val == 7: + # 해를 기록한다 + res.append(list(path)) + pre_order(root.left) + pre_order(root.right) + # 되돌리기 + path.pop() + ``` + +=== "C++" + + ```cpp title="preorder_traversal_ii_compact.cpp" + /* 전위 순회: 예제 2 */ + void preOrder(TreeNode *root) { + if (root == nullptr) { + return; + } + // 시도 + path.push_back(root); + if (root->val == 7) { + // 해를 기록한다 + res.push_back(path); + } + preOrder(root->left); + preOrder(root->right); + // 되돌리기 + path.pop_back(); + } + ``` + +=== "Java" + + ```java title="preorder_traversal_ii_compact.java" + /* 전위 순회: 예제 2 */ + void preOrder(TreeNode root) { + if (root == null) { + return; + } + // 시도 + path.add(root); + if (root.val == 7) { + // 해를 기록한다 + res.add(new ArrayList<>(path)); + } + preOrder(root.left); + preOrder(root.right); + // 되돌리기 + path.remove(path.size() - 1); + } + ``` + +=== "C#" + + ```csharp title="preorder_traversal_ii_compact.cs" + /* 전위 순회: 예제 2 */ + void PreOrder(TreeNode? root) { + if (root == null) { + return; + } + // 시도 + path.Add(root); + if (root.val == 7) { + // 해를 기록한다 + res.Add(new List(path)); + } + PreOrder(root.left); + PreOrder(root.right); + // 되돌리기 + path.RemoveAt(path.Count - 1); + } + ``` + +=== "Go" + + ```go title="preorder_traversal_ii_compact.go" + /* 전위 순회: 예제 2 */ + func preOrderII(root *TreeNode, res *[][]*TreeNode, path *[]*TreeNode) { + if root == nil { + return + } + // 시도 + *path = append(*path, root) + if root.Val.(int) == 7 { + // 해를 기록한다 + *res = append(*res, append([]*TreeNode{}, *path...)) + } + preOrderII(root.Left, res, path) + preOrderII(root.Right, res, path) + // 되돌리기 + *path = (*path)[:len(*path)-1] + } + ``` + +=== "Swift" + + ```swift title="preorder_traversal_ii_compact.swift" + /* 전위 순회: 예제 2 */ + func preOrder(root: TreeNode?) { + guard let root = root else { + return + } + // 시도 + path.append(root) + if root.val == 7 { + // 해를 기록한다 + res.append(path) + } + preOrder(root: root.left) + preOrder(root: root.right) + // 되돌리기 + path.removeLast() + } + ``` + +=== "JS" + + ```javascript title="preorder_traversal_ii_compact.js" + /* 전위 순회: 예제 2 */ + function preOrder(root, path, res) { + if (root === null) { + return; + } + // 시도 + path.push(root); + if (root.val === 7) { + // 해를 기록한다 + res.push([...path]); + } + preOrder(root.left, path, res); + preOrder(root.right, path, res); + // 되돌리기 + path.pop(); + } + ``` + +=== "TS" + + ```typescript title="preorder_traversal_ii_compact.ts" + /* 전위 순회: 예제 2 */ + function preOrder( + root: TreeNode | null, + path: TreeNode[], + res: TreeNode[][] + ): void { + if (root === null) { + return; + } + // 시도 + path.push(root); + if (root.val === 7) { + // 해를 기록한다 + res.push([...path]); + } + preOrder(root.left, path, res); + preOrder(root.right, path, res); + // 되돌리기 + path.pop(); + } + ``` + +=== "Dart" + + ```dart title="preorder_traversal_ii_compact.dart" + /* 전위 순회: 예제 2 */ + void preOrder( + TreeNode? root, + List path, + List> res, + ) { + if (root == null) { + return; + } + + // 시도 + path.add(root); + if (root.val == 7) { + // 해를 기록한다 + res.add(List.from(path)); + } + preOrder(root.left, path, res); + preOrder(root.right, path, res); + // 되돌리기 + path.removeLast(); + } + ``` + +=== "Rust" + + ```rust title="preorder_traversal_ii_compact.rs" + /* 전위 순회: 예제 2 */ + fn pre_order( + res: &mut Vec>>>, + path: &mut Vec>>, + root: Option<&Rc>>, + ) { + if root.is_none() { + return; + } + if let Some(node) = root { + // 시도 + path.push(node.clone()); + if node.borrow().val == 7 { + // 해를 기록한다 + res.push(path.clone()); + } + pre_order(res, path, node.borrow().left.as_ref()); + pre_order(res, path, node.borrow().right.as_ref()); + // 되돌리기 + path.pop(); + } + } + ``` + +=== "C" + + ```c title="preorder_traversal_ii_compact.c" + /* 전위 순회: 예제 2 */ + void preOrder(TreeNode *root) { + if (root == NULL) { + return; + } + // 시도 + path[pathSize++] = root; + if (root->val == 7) { + // 해를 기록한다 + for (int i = 0; i < pathSize; ++i) { + res[resSize][i] = path[i]; + } + resSize++; + } + preOrder(root->left); + preOrder(root->right); + // 되돌리기 + pathSize--; + } + ``` + +=== "Kotlin" + + ```kotlin title="preorder_traversal_ii_compact.kt" + /* 전위 순회: 예제 2 */ + fun preOrder(root: TreeNode?) { + if (root == null) { + return + } + // 시도 + path!!.add(root) + if (root._val == 7) { + // 해를 기록한다 + res!!.add(path!!.toMutableList()) + } + preOrder(root.left) + preOrder(root.right) + // 되돌리기 + path!!.removeAt(path!!.size - 1) + } + ``` + +=== "Ruby" + + ```ruby title="preorder_traversal_ii_compact.rb" + ### 전위 순회: 예제 2 ### + def pre_order(root) + return unless root + + # 시도 + $path << root + + # 해를 기록한다 + $res << $path.dup if root.val == 7 + + pre_order(root.left) + pre_order(root.right) + + # 되돌리기 + $path.pop + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +매번 '시도'할 때는 현재 노드를 `path`에 추가하여 경로를 기록한다. '되돌리기' 전에는 이 노드를 `path`에서 꺼내어 **이번 시도 이전의 상태를 복원해야 한다**. + +아래 그림(13-2)의 과정을 보면 **시도와 되돌리기를 각각 '전진'과 '취소'로 이해할 수 있으며**, 두 연산은 서로 반대 방향이다. + +=== "<1>" + ![시도와 되돌리기](backtracking_algorithm.assets/preorder_find_paths_step1.png){ class="animation-figure" } + +=== "<2>" + ![경로 탐색 2단계](backtracking_algorithm.assets/preorder_find_paths_step2.png){ class="animation-figure" } + +=== "<3>" + ![경로 탐색 3단계](backtracking_algorithm.assets/preorder_find_paths_step3.png){ class="animation-figure" } + +=== "<4>" + ![경로 탐색 4단계](backtracking_algorithm.assets/preorder_find_paths_step4.png){ class="animation-figure" } + +=== "<5>" + ![경로 탐색 5단계](backtracking_algorithm.assets/preorder_find_paths_step5.png){ class="animation-figure" } + +=== "<6>" + ![경로 탐색 6단계](backtracking_algorithm.assets/preorder_find_paths_step6.png){ class="animation-figure" } + +=== "<7>" + ![경로 탐색 7단계](backtracking_algorithm.assets/preorder_find_paths_step7.png){ class="animation-figure" } + +=== "<8>" + ![경로 탐색 8단계](backtracking_algorithm.assets/preorder_find_paths_step8.png){ class="animation-figure" } + +=== "<9>" + ![경로 탐색 9단계](backtracking_algorithm.assets/preorder_find_paths_step9.png){ class="animation-figure" } + +=== "<10>" + ![경로 탐색 10단계](backtracking_algorithm.assets/preorder_find_paths_step10.png){ class="animation-figure" } + +=== "<11>" + ![경로 탐색 11단계](backtracking_algorithm.assets/preorder_find_paths_step11.png){ class="animation-figure" } + +

그림 13-2   시도와 되돌리기

+ +## 13.1.2   가지치기 + +복잡한 백트래킹 문제에는 보통 하나 이상의 제약 조건이 있으며, **이 제약 조건을 '가지치기'에 활용할 수 있다**. + +!!! question "예제 3" + + 이진 트리에서 값이 $7$인 모든 노드를 찾아 루트 노드에서 각 노드까지의 경로를 반환하라. **단, 경로에 값이 $3$인 노드가 포함되면 안 된다**. + +이 제약을 만족하려면 **가지치기 연산을 추가해야 한다**. 탐색 중 값이 $3$인 노드를 만나면 더 탐색하지 않고 조기에 반환한다. 코드는 다음과 같다. + +=== "Python" + + ```python title="preorder_traversal_iii_compact.py" + def pre_order(root: TreeNode): + """전위 순회: 예제 3""" + # 가지치기 + if root is None or root.val == 3: + return + # 시도 + path.append(root) + if root.val == 7: + # 해를 기록한다 + res.append(list(path)) + pre_order(root.left) + pre_order(root.right) + # 되돌리기 + path.pop() + ``` + +=== "C++" + + ```cpp title="preorder_traversal_iii_compact.cpp" + /* 전위 순회: 예제 3 */ + void preOrder(TreeNode *root) { + // 가지치기 + if (root == nullptr || root->val == 3) { + return; + } + // 시도 + path.push_back(root); + if (root->val == 7) { + // 해를 기록한다 + res.push_back(path); + } + preOrder(root->left); + preOrder(root->right); + // 되돌리기 + path.pop_back(); + } + ``` + +=== "Java" + + ```java title="preorder_traversal_iii_compact.java" + /* 전위 순회: 예제 3 */ + void preOrder(TreeNode root) { + // 가지치기 + if (root == null || root.val == 3) { + return; + } + // 시도 + path.add(root); + if (root.val == 7) { + // 해를 기록한다 + res.add(new ArrayList<>(path)); + } + preOrder(root.left); + preOrder(root.right); + // 되돌리기 + path.remove(path.size() - 1); + } + ``` + +=== "C#" + + ```csharp title="preorder_traversal_iii_compact.cs" + /* 전위 순회: 예제 3 */ + void PreOrder(TreeNode? root) { + // 가지치기 + if (root == null || root.val == 3) { + return; + } + // 시도 + path.Add(root); + if (root.val == 7) { + // 해를 기록한다 + res.Add(new List(path)); + } + PreOrder(root.left); + PreOrder(root.right); + // 되돌리기 + path.RemoveAt(path.Count - 1); + } + ``` + +=== "Go" + + ```go title="preorder_traversal_iii_compact.go" + /* 전위 순회: 예제 3 */ + func preOrderIII(root *TreeNode, res *[][]*TreeNode, path *[]*TreeNode) { + // 가지치기 + if root == nil || root.Val == 3 { + return + } + // 시도 + *path = append(*path, root) + if root.Val.(int) == 7 { + // 해를 기록한다 + *res = append(*res, append([]*TreeNode{}, *path...)) + } + preOrderIII(root.Left, res, path) + preOrderIII(root.Right, res, path) + // 되돌리기 + *path = (*path)[:len(*path)-1] + } + ``` + +=== "Swift" + + ```swift title="preorder_traversal_iii_compact.swift" + /* 전위 순회: 예제 3 */ + func preOrder(root: TreeNode?) { + // 가지치기 + guard let root = root, root.val != 3 else { + return + } + // 시도 + path.append(root) + if root.val == 7 { + // 해를 기록한다 + res.append(path) + } + preOrder(root: root.left) + preOrder(root: root.right) + // 되돌리기 + path.removeLast() + } + ``` + +=== "JS" + + ```javascript title="preorder_traversal_iii_compact.js" + /* 전위 순회: 예제 3 */ + function preOrder(root, path, res) { + // 가지치기 + if (root === null || root.val === 3) { + return; + } + // 시도 + path.push(root); + if (root.val === 7) { + // 해를 기록한다 + res.push([...path]); + } + preOrder(root.left, path, res); + preOrder(root.right, path, res); + // 되돌리기 + path.pop(); + } + ``` + +=== "TS" + + ```typescript title="preorder_traversal_iii_compact.ts" + /* 전위 순회: 예제 3 */ + function preOrder( + root: TreeNode | null, + path: TreeNode[], + res: TreeNode[][] + ): void { + // 가지치기 + if (root === null || root.val === 3) { + return; + } + // 시도 + path.push(root); + if (root.val === 7) { + // 해를 기록한다 + res.push([...path]); + } + preOrder(root.left, path, res); + preOrder(root.right, path, res); + // 되돌리기 + path.pop(); + } + ``` + +=== "Dart" + + ```dart title="preorder_traversal_iii_compact.dart" + /* 전위 순회: 예제 3 */ + void preOrder( + TreeNode? root, + List path, + List> res, + ) { + if (root == null || root.val == 3) { + return; + } + + // 시도 + path.add(root); + if (root.val == 7) { + // 해를 기록한다 + res.add(List.from(path)); + } + preOrder(root.left, path, res); + preOrder(root.right, path, res); + // 되돌리기 + path.removeLast(); + } + ``` + +=== "Rust" + + ```rust title="preorder_traversal_iii_compact.rs" + /* 전위 순회: 예제 3 */ + fn pre_order( + res: &mut Vec>>>, + path: &mut Vec>>, + root: Option<&Rc>>, + ) { + // 가지치기 + if root.is_none() || root.as_ref().unwrap().borrow().val == 3 { + return; + } + if let Some(node) = root { + // 시도 + path.push(node.clone()); + if node.borrow().val == 7 { + // 해를 기록한다 + res.push(path.clone()); + } + pre_order(res, path, node.borrow().left.as_ref()); + pre_order(res, path, node.borrow().right.as_ref()); + // 되돌리기 + path.pop(); + } + } + ``` + +=== "C" + + ```c title="preorder_traversal_iii_compact.c" + /* 전위 순회: 예제 3 */ + void preOrder(TreeNode *root) { + // 가지치기 + if (root == NULL || root->val == 3) { + return; + } + // 시도 + path[pathSize++] = root; + if (root->val == 7) { + // 해를 기록한다 + for (int i = 0; i < pathSize; i++) { + res[resSize][i] = path[i]; + } + resSize++; + } + preOrder(root->left); + preOrder(root->right); + // 되돌리기 + pathSize--; + } + ``` + +=== "Kotlin" + + ```kotlin title="preorder_traversal_iii_compact.kt" + /* 전위 순회: 예제 3 */ + fun preOrder(root: TreeNode?) { + // 가지치기 + if (root == null || root._val == 3) { + return + } + // 시도 + path!!.add(root) + if (root._val == 7) { + // 해를 기록한다 + res!!.add(path!!.toMutableList()) + } + preOrder(root.left) + preOrder(root.right) + // 되돌리기 + path!!.removeAt(path!!.size - 1) + } + ``` + +=== "Ruby" + + ```ruby title="preorder_traversal_iii_compact.rb" + ### 전위 순회: 예제 3 ### + def pre_order(root) + # 가지치기 + return if !root || root.val == 3 + + # 시도 + $path.append(root) + + # 해를 기록한다 + $res << $path.dup if root.val == 7 + + pre_order(root.left) + pre_order(root.right) + + # 되돌리기 + $path.pop + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +'가지치기'는 매우 직관적인 이름이다. 아래 그림(13-3)과 같이 탐색 중 **제약 조건을 만족하지 않는 탐색 가지를 '잘라 내어'** 의미 없는 시도를 많이 피하고 탐색 효율을 높인다. + +![제약 조건에 따른 가지치기](backtracking_algorithm.assets/preorder_find_constrained_paths.png){ class="animation-figure" } + +

그림 13-3   제약 조건에 따른 가지치기

+ +## 13.1.3   기본 틀 코드 + +이제 백트래킹의 '시도, 되돌리기, 가지치기'라는 기본 틀을 추출하여 코드의 범용성을 높여 보자. + +아래 기본 틀 코드에서 `state`는 문제의 현재 상태를, `choices`는 그 상태에서 가능한 선택들을 나타낸다. + +=== "Python" + + ```python title="" + def backtrack(state: State, choices: list[choice], res: list[state]): + """백트래킹 알고리즘의 기본 틀""" + # 해인지 확인 + if is_solution(state): + # 해 기록 + record_solution(state, res) + # 더 이상 탐색하지 않는다 + return + # 모든 선택지 순회 + for choice in choices: + # 가지치기: 선택이 유효한지 확인 + if is_valid(state, choice): + # 시도: 선택하고 상태 갱신 + make_choice(state, choice) + backtrack(state, choices, res) + # 되돌리기: 선택을 취소하고 이전 상태 복원 + undo_choice(state, choice) + ``` + +=== "C++" + + ```cpp title="" + /* 백트래킹 알고리즘의 기본 틀 */ + void backtrack(State *state, vector &choices, vector &res) { + // 해인지 확인 + if (isSolution(state)) { + // 해 기록 + recordSolution(state, res); + // 더 이상 탐색하지 않는다 + return; + } + // 모든 선택지 순회 + for (Choice choice : choices) { + // 가지치기: 선택이 유효한지 확인 + if (isValid(state, choice)) { + // 시도: 선택하고 상태 갱신 + makeChoice(state, choice); + backtrack(state, choices, res); + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undoChoice(state, choice); + } + } + } + ``` + +=== "Java" + + ```java title="" + /* 백트래킹 알고리즘의 기본 틀 */ + void backtrack(State state, List choices, List res) { + // 해인지 확인 + if (isSolution(state)) { + // 해 기록 + recordSolution(state, res); + // 더 이상 탐색하지 않는다 + return; + } + // 모든 선택지 순회 + for (Choice choice : choices) { + // 가지치기: 선택이 유효한지 확인 + if (isValid(state, choice)) { + // 시도: 선택하고 상태 갱신 + makeChoice(state, choice); + backtrack(state, choices, res); + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undoChoice(state, choice); + } + } + } + ``` + +=== "C#" + + ```csharp title="" + /* 백트래킹 알고리즘의 기본 틀 */ + void Backtrack(State state, List choices, List res) { + // 해인지 확인 + if (IsSolution(state)) { + // 해 기록 + RecordSolution(state, res); + // 더 이상 탐색하지 않는다 + return; + } + // 모든 선택지 순회 + foreach (Choice choice in choices) { + // 가지치기: 선택이 유효한지 확인 + if (IsValid(state, choice)) { + // 시도: 선택하고 상태 갱신 + MakeChoice(state, choice); + Backtrack(state, choices, res); + // 되돌리기: 선택을 취소하고 이전 상태 복원 + UndoChoice(state, choice); + } + } + } + ``` + +=== "Go" + + ```go title="" + /* 백트래킹 알고리즘의 기본 틀 */ + func backtrack(state *State, choices []Choice, res *[]State) { + // 해인지 확인 + if isSolution(state) { + // 해 기록 + recordSolution(state, res) + // 더 이상 탐색하지 않는다 + return + } + // 모든 선택지 순회 + for _, choice := range choices { + // 가지치기: 선택이 유효한지 확인 + if isValid(state, choice) { + // 시도: 선택하고 상태 갱신 + makeChoice(state, choice) + backtrack(state, choices, res) + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undoChoice(state, choice) + } + } + } + ``` + +=== "Swift" + + ```swift title="" + /* 백트래킹 알고리즘의 기본 틀 */ + func backtrack(state: inout State, choices: [Choice], res: inout [State]) { + // 해인지 확인 + if isSolution(state: state) { + // 해 기록 + recordSolution(state: state, res: &res) + // 더 이상 탐색하지 않는다 + return + } + // 모든 선택지 순회 + for choice in choices { + // 가지치기: 선택이 유효한지 확인 + if isValid(state: state, choice: choice) { + // 시도: 선택하고 상태 갱신 + makeChoice(state: &state, choice: choice) + backtrack(state: &state, choices: choices, res: &res) + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undoChoice(state: &state, choice: choice) + } + } + } + ``` + +=== "JS" + + ```javascript title="" + /* 백트래킹 알고리즘의 기본 틀 */ + function backtrack(state, choices, res) { + // 해인지 확인 + if (isSolution(state)) { + // 해 기록 + recordSolution(state, res); + // 더 이상 탐색하지 않는다 + return; + } + // 모든 선택지 순회 + for (let choice of choices) { + // 가지치기: 선택이 유효한지 확인 + if (isValid(state, choice)) { + // 시도: 선택하고 상태 갱신 + makeChoice(state, choice); + backtrack(state, choices, res); + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undoChoice(state, choice); + } + } + } + ``` + +=== "TS" + + ```typescript title="" + /* 백트래킹 알고리즘의 기본 틀 */ + function backtrack(state: State, choices: Choice[], res: State[]): void { + // 해인지 확인 + if (isSolution(state)) { + // 해 기록 + recordSolution(state, res); + // 더 이상 탐색하지 않는다 + return; + } + // 모든 선택지 순회 + for (let choice of choices) { + // 가지치기: 선택이 유효한지 확인 + if (isValid(state, choice)) { + // 시도: 선택하고 상태 갱신 + makeChoice(state, choice); + backtrack(state, choices, res); + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undoChoice(state, choice); + } + } + } + ``` + +=== "Dart" + + ```dart title="" + /* 백트래킹 알고리즘의 기본 틀 */ + void backtrack(State state, List, List res) { + // 해인지 확인 + if (isSolution(state)) { + // 해 기록 + recordSolution(state, res); + // 더 이상 탐색하지 않는다 + return; + } + // 모든 선택지 순회 + for (Choice choice in choices) { + // 가지치기: 선택이 유효한지 확인 + if (isValid(state, choice)) { + // 시도: 선택하고 상태 갱신 + makeChoice(state, choice); + backtrack(state, choices, res); + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undoChoice(state, choice); + } + } + } + ``` + +=== "Rust" + + ```rust title="" + /* 백트래킹 알고리즘의 기본 틀 */ + fn backtrack(state: &mut State, choices: &Vec, res: &mut Vec) { + // 해인지 확인 + if is_solution(state) { + // 해 기록 + record_solution(state, res); + // 더 이상 탐색하지 않는다 + return; + } + // 모든 선택지 순회 + for choice in choices { + // 가지치기: 선택이 유효한지 확인 + if is_valid(state, choice) { + // 시도: 선택하고 상태 갱신 + make_choice(state, choice); + backtrack(state, choices, res); + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undo_choice(state, choice); + } + } + } + ``` + +=== "C" + + ```c title="" + /* 백트래킹 알고리즘의 기본 틀 */ + void backtrack(State *state, Choice *choices, int numChoices, State *res, int numRes) { + // 해인지 확인 + if (isSolution(state)) { + // 해 기록 + recordSolution(state, res, numRes); + // 더 이상 탐색하지 않는다 + return; + } + // 모든 선택지 순회 + for (int i = 0; i < numChoices; i++) { + // 가지치기: 선택이 유효한지 확인 + if (isValid(state, &choices[i])) { + // 시도: 선택하고 상태 갱신 + makeChoice(state, &choices[i]); + backtrack(state, choices, numChoices, res, numRes); + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undoChoice(state, &choices[i]); + } + } + } + ``` + +=== "Kotlin" + + ```kotlin title="" + /* 백트래킹 알고리즘의 기본 틀 */ + fun backtrack(state: State?, choices: List, res: List?) { + // 해인지 확인 + if (isSolution(state)) { + // 해 기록 + recordSolution(state, res) + // 더 이상 탐색하지 않는다 + return + } + // 모든 선택지 순회 + for (choice in choices) { + // 가지치기: 선택이 유효한지 확인 + if (isValid(state, choice)) { + // 시도: 선택하고 상태 갱신 + makeChoice(state, choice) + backtrack(state, choices, res) + // 되돌리기: 선택을 취소하고 이전 상태 복원 + undoChoice(state, choice) + } + } + } + ``` + +=== "Ruby" + + ```ruby title="" + ### 백트래킹 알고리즘의 기본 틀 ### + def backtrack(state, choices, res) + # 해인지 확인 + if is_solution?(state) + # 해 기록 + record_solution(state, res) + return + end + + # 모든 선택지 순회 + for choice in choices + # 가지치기: 선택이 유효한지 확인 + if is_valid?(state, choice) + # 시도: 선택하고 상태 갱신 + make_choice(state, choice) + backtrack(state, choices, res) + # 되돌리기: 선택을 취소하고 이전 상태 복원 + undo_choice(state, choice) + end + end + end + ``` + +이제 기본 틀 코드로 예제 3을 해결해 보자. 상태 `state`는 노드 순회 경로, 선택지 `choices`는 현재 노드의 왼쪽과 오른쪽 자식 노드, 결과 `res`는 경로들의 리스트다. + +=== "Python" + + ```python title="preorder_traversal_iii_template.py" + def is_solution(state: list[TreeNode]) -> bool: + """현재 상태가 해인지 판단""" + return state and state[-1].val == 7 + + def record_solution(state: list[TreeNode], res: list[list[TreeNode]]): + """해를 기록한다""" + res.append(list(state)) + + def is_valid(state: list[TreeNode], choice: TreeNode) -> bool: + """현재 상태에서 해당 선택이 유효한지 판단""" + return choice is not None and choice.val != 3 + + def make_choice(state: list[TreeNode], choice: TreeNode): + """상태 갱신""" + state.append(choice) + + def undo_choice(state: list[TreeNode], choice: TreeNode): + """상태 복원""" + state.pop() + + def backtrack( + state: list[TreeNode], choices: list[TreeNode], res: list[list[TreeNode]] + ): + """백트래킹 알고리즘: 예제 3""" + # 해인지 확인한다 + if is_solution(state): + # 해를 기록한다 + record_solution(state, res) + # 모든 선택지를 순회한다 + for choice in choices: + # 가지치기: 선택이 유효한지 확인한다 + if is_valid(state, choice): + # 시도: 선택하고 상태를 갱신한다 + make_choice(state, choice) + # 다음 선택을 진행한다 + backtrack(state, [choice.left, choice.right], res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undo_choice(state, choice) + ``` + +=== "C++" + + ```cpp title="preorder_traversal_iii_template.cpp" + /* 현재 상태가 해인지 판단 */ + bool isSolution(vector &state) { + return !state.empty() && state.back()->val == 7; + } + + /* 해를 기록한다 */ + void recordSolution(vector &state, vector> &res) { + res.push_back(state); + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + bool isValid(vector &state, TreeNode *choice) { + return choice != nullptr && choice->val != 3; + } + + /* 상태 갱신 */ + void makeChoice(vector &state, TreeNode *choice) { + state.push_back(choice); + } + + /* 상태 복원 */ + void undoChoice(vector &state, TreeNode *choice) { + state.pop_back(); + } + + /* 백트래킹 알고리즘: 예제 3 */ + void backtrack(vector &state, vector &choices, vector> &res) { + // 해인지 확인한다 + if (isSolution(state)) { + // 해를 기록한다 + recordSolution(state, res); + } + // 모든 선택지를 순회한다 + for (TreeNode *choice : choices) { + // 가지치기: 선택이 유효한지 확인한다 + if (isValid(state, choice)) { + // 시도: 선택하고 상태를 갱신한다 + makeChoice(state, choice); + // 다음 선택을 진행한다 + vector nextChoices{choice->left, choice->right}; + backtrack(state, nextChoices, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undoChoice(state, choice); + } + } + } + ``` + +=== "Java" + + ```java title="preorder_traversal_iii_template.java" + /* 현재 상태가 해인지 판단 */ + boolean isSolution(List state) { + return !state.isEmpty() && state.get(state.size() - 1).val == 7; + } + + /* 해를 기록한다 */ + void recordSolution(List state, List> res) { + res.add(new ArrayList<>(state)); + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + boolean isValid(List state, TreeNode choice) { + return choice != null && choice.val != 3; + } + + /* 상태 갱신 */ + void makeChoice(List state, TreeNode choice) { + state.add(choice); + } + + /* 상태 복원 */ + void undoChoice(List state, TreeNode choice) { + state.remove(state.size() - 1); + } + + /* 백트래킹 알고리즘: 예제 3 */ + void backtrack(List state, List choices, List> res) { + // 해인지 확인한다 + if (isSolution(state)) { + // 해를 기록한다 + recordSolution(state, res); + } + // 모든 선택지를 순회한다 + for (TreeNode choice : choices) { + // 가지치기: 선택이 유효한지 확인한다 + if (isValid(state, choice)) { + // 시도: 선택하고 상태를 갱신한다 + makeChoice(state, choice); + // 다음 선택을 진행한다 + backtrack(state, Arrays.asList(choice.left, choice.right), res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undoChoice(state, choice); + } + } + } + ``` + +=== "C#" + + ```csharp title="preorder_traversal_iii_template.cs" + /* 현재 상태가 해인지 판단 */ + bool IsSolution(List state) { + return state.Count != 0 && state[^1].val == 7; + } + + /* 해를 기록한다 */ + void RecordSolution(List state, List> res) { + res.Add(new List(state)); + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + bool IsValid(List state, TreeNode choice) { + return choice != null && choice.val != 3; + } + + /* 상태 갱신 */ + void MakeChoice(List state, TreeNode choice) { + state.Add(choice); + } + + /* 상태 복원 */ + void UndoChoice(List state, TreeNode choice) { + state.RemoveAt(state.Count - 1); + } + + /* 백트래킹 알고리즘: 예제 3 */ + void Backtrack(List state, List choices, List> res) { + // 해인지 확인한다 + if (IsSolution(state)) { + // 해를 기록한다 + RecordSolution(state, res); + } + // 모든 선택지를 순회한다 + foreach (TreeNode choice in choices) { + // 가지치기: 선택이 유효한지 확인한다 + if (IsValid(state, choice)) { + // 시도: 선택하고 상태를 갱신한다 + MakeChoice(state, choice); + // 다음 선택을 진행한다 + Backtrack(state, [choice.left!, choice.right!], res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + UndoChoice(state, choice); + } + } + } + ``` + +=== "Go" + + ```go title="preorder_traversal_iii_template.go" + /* 현재 상태가 해인지 판단 */ + func isSolution(state *[]*TreeNode) bool { + return len(*state) != 0 && (*state)[len(*state)-1].Val == 7 + } + + /* 해를 기록한다 */ + func recordSolution(state *[]*TreeNode, res *[][]*TreeNode) { + *res = append(*res, append([]*TreeNode{}, *state...)) + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + func isValid(state *[]*TreeNode, choice *TreeNode) bool { + return choice != nil && choice.Val != 3 + } + + /* 상태 갱신 */ + func makeChoice(state *[]*TreeNode, choice *TreeNode) { + *state = append(*state, choice) + } + + /* 상태 복원 */ + func undoChoice(state *[]*TreeNode, choice *TreeNode) { + *state = (*state)[:len(*state)-1] + } + + /* 백트래킹 알고리즘: 예제 3 */ + func backtrackIII(state *[]*TreeNode, choices *[]*TreeNode, res *[][]*TreeNode) { + // 해인지 확인한다 + if isSolution(state) { + // 해를 기록한다 + recordSolution(state, res) + } + // 모든 선택지를 순회한다 + for _, choice := range *choices { + // 가지치기: 선택이 유효한지 확인한다 + if isValid(state, choice) { + // 시도: 선택하고 상태를 갱신한다 + makeChoice(state, choice) + // 다음 선택을 진행한다 + temp := make([]*TreeNode, 0) + temp = append(temp, choice.Left, choice.Right) + backtrackIII(state, &temp, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undoChoice(state, choice) + } + } + } + ``` + +=== "Swift" + + ```swift title="preorder_traversal_iii_template.swift" + /* 현재 상태가 해인지 판단 */ + func isSolution(state: [TreeNode]) -> Bool { + !state.isEmpty && state.last!.val == 7 + } + + /* 해를 기록한다 */ + func recordSolution(state: [TreeNode], res: inout [[TreeNode]]) { + res.append(state) + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + func isValid(state: [TreeNode], choice: TreeNode?) -> Bool { + choice != nil && choice!.val != 3 + } + + /* 상태 갱신 */ + func makeChoice(state: inout [TreeNode], choice: TreeNode) { + state.append(choice) + } + + /* 상태 복원 */ + func undoChoice(state: inout [TreeNode], choice: TreeNode) { + state.removeLast() + } + + /* 백트래킹 알고리즘: 예제 3 */ + func backtrack(state: inout [TreeNode], choices: [TreeNode], res: inout [[TreeNode]]) { + // 해인지 확인한다 + if isSolution(state: state) { + recordSolution(state: state, res: &res) + } + // 모든 선택지를 순회한다 + for choice in choices { + // 가지치기: 선택이 유효한지 확인한다 + if isValid(state: state, choice: choice) { + // 시도: 선택하고 상태를 갱신한다 + makeChoice(state: &state, choice: choice) + // 다음 선택을 진행한다 + backtrack(state: &state, choices: [choice.left, choice.right].compactMap { $0 }, res: &res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undoChoice(state: &state, choice: choice) + } + } + } + ``` + +=== "JS" + + ```javascript title="preorder_traversal_iii_template.js" + /* 현재 상태가 해인지 판단 */ + function isSolution(state) { + return state && state[state.length - 1]?.val === 7; + } + + /* 해를 기록한다 */ + function recordSolution(state, res) { + res.push([...state]); + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + function isValid(state, choice) { + return choice !== null && choice.val !== 3; + } + + /* 상태 갱신 */ + function makeChoice(state, choice) { + state.push(choice); + } + + /* 상태 복원 */ + function undoChoice(state) { + state.pop(); + } + + /* 백트래킹 알고리즘: 예제 3 */ + function backtrack(state, choices, res) { + // 해인지 확인한다 + if (isSolution(state)) { + // 해를 기록한다 + recordSolution(state, res); + } + // 모든 선택지를 순회한다 + for (const choice of choices) { + // 가지치기: 선택이 유효한지 확인한다 + if (isValid(state, choice)) { + // 시도: 선택하고 상태를 갱신한다 + makeChoice(state, choice); + // 다음 선택을 진행한다 + backtrack(state, [choice.left, choice.right], res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undoChoice(state); + } + } + } + ``` + +=== "TS" + + ```typescript title="preorder_traversal_iii_template.ts" + /* 현재 상태가 해인지 판단 */ + function isSolution(state: TreeNode[]): boolean { + return state && state[state.length - 1]?.val === 7; + } + + /* 해를 기록한다 */ + function recordSolution(state: TreeNode[], res: TreeNode[][]): void { + res.push([...state]); + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + function isValid(state: TreeNode[], choice: TreeNode): boolean { + return choice !== null && choice.val !== 3; + } + + /* 상태 갱신 */ + function makeChoice(state: TreeNode[], choice: TreeNode): void { + state.push(choice); + } + + /* 상태 복원 */ + function undoChoice(state: TreeNode[]): void { + state.pop(); + } + + /* 백트래킹 알고리즘: 예제 3 */ + function backtrack( + state: TreeNode[], + choices: TreeNode[], + res: TreeNode[][] + ): void { + // 해인지 확인한다 + if (isSolution(state)) { + // 해를 기록한다 + recordSolution(state, res); + } + // 모든 선택지를 순회한다 + for (const choice of choices) { + // 가지치기: 선택이 유효한지 확인한다 + if (isValid(state, choice)) { + // 시도: 선택하고 상태를 갱신한다 + makeChoice(state, choice); + // 다음 선택을 진행한다 + backtrack(state, [choice.left, choice.right], res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undoChoice(state); + } + } + } + ``` + +=== "Dart" + + ```dart title="preorder_traversal_iii_template.dart" + /* 현재 상태가 해인지 판단 */ + bool isSolution(List state) { + return state.isNotEmpty && state.last.val == 7; + } + + /* 해를 기록한다 */ + void recordSolution(List state, List> res) { + res.add(List.from(state)); + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + bool isValid(List state, TreeNode? choice) { + return choice != null && choice.val != 3; + } + + /* 상태 갱신 */ + void makeChoice(List state, TreeNode? choice) { + state.add(choice!); + } + + /* 상태 복원 */ + void undoChoice(List state, TreeNode? choice) { + state.removeLast(); + } + + /* 백트래킹 알고리즘: 예제 3 */ + void backtrack( + List state, + List choices, + List> res, + ) { + // 해인지 확인한다 + if (isSolution(state)) { + // 해를 기록한다 + recordSolution(state, res); + } + // 모든 선택지를 순회한다 + for (TreeNode? choice in choices) { + // 가지치기: 선택이 유효한지 확인한다 + if (isValid(state, choice)) { + // 시도: 선택하고 상태를 갱신한다 + makeChoice(state, choice); + // 다음 선택을 진행한다 + backtrack(state, [choice!.left, choice.right], res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undoChoice(state, choice); + } + } + } + ``` + +=== "Rust" + + ```rust title="preorder_traversal_iii_template.rs" + /* 현재 상태가 해인지 판단 */ + fn is_solution(state: &mut Vec>>) -> bool { + return !state.is_empty() && state.last().unwrap().borrow().val == 7; + } + + /* 해를 기록한다 */ + fn record_solution( + state: &mut Vec>>, + res: &mut Vec>>>, + ) { + res.push(state.clone()); + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + fn is_valid(_: &mut Vec>>, choice: Option<&Rc>>) -> bool { + return choice.is_some() && choice.unwrap().borrow().val != 3; + } + + /* 상태 갱신 */ + fn make_choice(state: &mut Vec>>, choice: Rc>) { + state.push(choice); + } + + /* 상태 복원 */ + fn undo_choice(state: &mut Vec>>, _: Rc>) { + state.pop(); + } + + /* 백트래킹 알고리즘: 예제 3 */ + fn backtrack( + state: &mut Vec>>, + choices: &Vec>>>, + res: &mut Vec>>>, + ) { + // 해인지 확인한다 + if is_solution(state) { + // 해를 기록한다 + record_solution(state, res); + } + // 모든 선택지를 순회한다 + for &choice in choices.iter() { + // 가지치기: 선택이 유효한지 확인한다 + if is_valid(state, choice) { + // 시도: 선택하고 상태를 갱신한다 + make_choice(state, choice.unwrap().clone()); + // 다음 선택을 진행한다 + backtrack( + state, + &vec![ + choice.unwrap().borrow().left.as_ref(), + choice.unwrap().borrow().right.as_ref(), + ], + res, + ); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undo_choice(state, choice.unwrap().clone()); + } + } + } + ``` + +=== "C" + + ```c title="preorder_traversal_iii_template.c" + /* 현재 상태가 해인지 판단 */ + bool isSolution(void) { + return pathSize > 0 && path[pathSize - 1]->val == 7; + } + + /* 해를 기록한다 */ + void recordSolution(void) { + for (int i = 0; i < pathSize; i++) { + res[resSize][i] = path[i]; + } + resSize++; + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + bool isValid(TreeNode *choice) { + return choice != NULL && choice->val != 3; + } + + /* 상태 갱신 */ + void makeChoice(TreeNode *choice) { + path[pathSize++] = choice; + } + + /* 상태 복원 */ + void undoChoice(void) { + pathSize--; + } + + /* 백트래킹 알고리즘: 예제 3 */ + void backtrack(TreeNode *choices[2]) { + // 해인지 확인한다 + if (isSolution()) { + // 해를 기록한다 + recordSolution(); + } + // 모든 선택지를 순회한다 + for (int i = 0; i < 2; i++) { + TreeNode *choice = choices[i]; + // 가지치기: 선택이 유효한지 확인한다 + if (isValid(choice)) { + // 시도: 선택하고 상태를 갱신한다 + makeChoice(choice); + // 다음 선택을 진행한다 + TreeNode *nextChoices[2] = {choice->left, choice->right}; + backtrack(nextChoices); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undoChoice(); + } + } + } + ``` + +=== "Kotlin" + + ```kotlin title="preorder_traversal_iii_template.kt" + /* 현재 상태가 해인지 판단 */ + fun isSolution(state: MutableList): Boolean { + return state.isNotEmpty() && state[state.size - 1]?._val == 7 + } + + /* 해를 기록한다 */ + fun recordSolution(state: MutableList?, res: MutableList?>) { + res.add(state!!.toMutableList()) + } + + /* 현재 상태에서 해당 선택이 유효한지 판단 */ + fun isValid(state: MutableList?, choice: TreeNode?): Boolean { + return choice != null && choice._val != 3 + } + + /* 상태 갱신 */ + fun makeChoice(state: MutableList, choice: TreeNode?) { + state.add(choice) + } + + /* 상태 복원 */ + fun undoChoice(state: MutableList, choice: TreeNode?) { + state.removeLast() + } + + /* 백트래킹 알고리즘: 예제 3 */ + fun backtrack( + state: MutableList, + choices: MutableList, + res: MutableList?> + ) { + // 해인지 확인한다 + if (isSolution(state)) { + // 해를 기록한다 + recordSolution(state, res) + } + // 모든 선택지를 순회한다 + for (choice in choices) { + // 가지치기: 선택이 유효한지 확인한다 + if (isValid(state, choice)) { + // 시도: 선택하고 상태를 갱신한다 + makeChoice(state, choice) + // 다음 선택을 진행한다 + backtrack(state, mutableListOf(choice!!.left, choice.right), res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undoChoice(state, choice) + } + } + } + ``` + +=== "Ruby" + + ```ruby title="preorder_traversal_iii_template.rb" + ### 현재 상태가 해인지 판단 ### + def is_solution?(state) + !state.empty? && state.last.val == 7 + end + + ### 해를 기록한다 ### + def record_solution(state, res) + res << state.dup + end + + ### 현재 상태에서 해당 선택이 유효한지 판단 ### + def is_valid?(state, choice) + choice && choice.val != 3 + end + + ### 상태 갱신 ### + def make_choice(state, choice) + state << choice + end + + ### 상태 복원 ### + def undo_choice(state, choice) + state.pop + end + + ### 백트래킹 알고리즘: 예제 3 ### + def backtrack(state, choices, res) + # 해인지 확인한다 + record_solution(state, res) if is_solution?(state) + + # 모든 선택지를 순회한다 + for choice in choices + # 가지치기: 선택이 유효한지 확인한다 + if is_valid?(state, choice) + # 시도: 선택하고 상태를 갱신한다 + make_choice(state, choice) + # 다음 선택을 진행한다 + backtrack(state, [choice.left, choice.right], res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + undo_choice(state, choice) + end + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +문제의 요구에 따르면 값이 $7$인 노드를 찾은 뒤에도 탐색을 계속해야 한다. **따라서 해를 기록한 뒤의 `return` 문을 삭제해야 한다**. 아래 그림(13-4)은 `return` 문을 유지하거나 삭제했을 때의 탐색 과정을 비교한다. + +![return 유지 여부에 따른 탐색 과정 비교](backtracking_algorithm.assets/backtrack_remove_return_or_not.png){ class="animation-figure" } + +

그림 13-4   return 유지 여부에 따른 탐색 과정 비교

+ +전위 순회 기반 코드에 비해 백트래킹 기본 틀을 사용한 코드는 다소 장황하지만 범용성이 더 좋다. 실제로 **많은 백트래킹 문제를 이 틀 안에서 해결할 수 있다**. 구체적인 문제에 맞게 `state`와 `choices`를 정의하고, 틀 안의 각 메서드만 구현하면 된다. + +## 13.1.4   주요 용어 + +알고리즘 문제를 더 명확히 분석하기 위해 백트래킹에서 자주 쓰는 용어를 정리하고, 예제 3에 대응하는 사례를 아래 표(13-1)에 나타냈다. + +

표 13-1   백트래킹 알고리즘의 주요 용어

+ +
+ +| 용어 | 정의 | 예제 3 | +| ---------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| 해(solution) | 문제의 특정 조건을 만족하는 답으로, 하나 또는 여러 개일 수 있다 | 루트 노드에서 노드 $7$까지 제약 조건을 만족하는 모든 경로 | +| 제약 조건(constraint) | 해의 유효성을 제한하는 조건으로, 보통 가지치기에 사용한다 | 경로에 노드 $3$이 포함되지 않는다 | +| 상태(state) | 이미 내린 선택을 포함하여 특정 시점의 문제 상황을 나타낸다 | 현재까지 방문한 노드의 경로, 즉 노드 리스트 `path` | +| 시도(attempt) | 가능한 선택으로 해 공간을 탐색하는 과정으로, 선택, 상태 갱신, 해인지 확인하는 작업을 포함한다 | 왼쪽(오른쪽) 자식 노드를 재귀적으로 방문하고 `path`에 추가한 뒤 값이 $7$인지 확인한다 | +| 되돌리기(backtracking) | 제약 조건을 만족하지 않는 상태를 만나면 이전 선택을 취소하고 직전 상태로 돌아간다 | 리프 노드를 지나거나 노드 방문을 마치거나 값이 $3$인 노드를 만나면 탐색을 멈추고 함수가 반환된다 | +| 가지치기(pruning) | 문제의 특성과 제약 조건에 따라 의미 없는 탐색 경로를 피하여 효율을 높이는 방법이다 | 값이 $3$인 노드를 만나면 탐색을 더 진행하지 않는다 | + +
+ +!!! tip "참고" + + 문제, 해, 상태 등의 개념은 분할 정복, 백트래킹, 동적 계획법, 탐욕 알고리즘 등에서 공통으로 사용된다. + +## 13.1.5   장점과 한계 + +백트래킹은 본질적으로 깊이 우선 탐색 알고리즘으로, 조건을 만족하는 해를 찾을 때까지 가능한 모든 해결 방법을 시도한다. 모든 가능한 해를 찾을 수 있으며, 적절히 가지치기하면 매우 효율적이라는 장점이 있다. + +하지만 규모가 크거나 복잡한 문제에서는 **백트래킹의 실행 효율을 받아들이기 어려울 수 있다**. + +- **시간**: 상태 공간의 모든 가능성을 순회해야 하는 경우가 많아 시간 복잡도가 지수 또는 팩토리얼 수준에 이를 수 있다. +- **공간**: 재귀 호출 중 현재 상태(경로, 가지치기용 보조 변수 등)를 저장해야 하므로 깊이가 크면 공간 요구량도 매우 커질 수 있다. + +그렇더라도 **일부 탐색 문제와 제약 만족 문제에서는 백트래킹이 여전히 최선의 해결 방법이다**. 어떤 선택이 유효한 해를 만들지 예측할 수 없으므로 가능한 모든 선택을 순회해야 하기 때문이다. 이때는 **효율을 어떻게 최적화하느냐가 핵심**이며, 대표적인 방법은 두 가지다. + +- **가지치기**: 해가 나올 수 없는 경로를 탐색하지 않아 시간과 공간을 절약한다. +- **휴리스틱 탐색**: 탐색에 전략이나 추정값을 도입하여 유효한 해가 나올 가능성이 높은 경로를 먼저 탐색한다. + +## 13.1.6   대표적인 백트래킹 문제 + +백트래킹은 많은 탐색 문제, 제약 만족 문제, 조합 최적화 문제를 해결하는 데 사용할 수 있다. + +**탐색 문제**: 특정 조건을 만족하는 해결 방법을 찾는 것이 목표다. + +- 모든 순열 문제: 집합이 주어졌을 때 가능한 모든 배열 순서를 구한다. +- 부분 집합 합 문제: 집합과 목표 합이 주어졌을 때 원소의 합이 목표 합인 모든 부분 집합을 찾는다. +- 하노이의 탑 문제: 기둥 세 개와 크기가 다른 원판들이 주어졌을 때 모든 원판을 한 기둥에서 다른 기둥으로 옮긴다. 한 번에 하나만 옮길 수 있고 큰 원판을 작은 원판 위에 놓으면 안 된다. + +**제약 만족 문제**: 모든 제약 조건을 만족하는 해를 찾는 것이 목표다. + +- $n$-퀸: $n \times n$ 체스판에 퀸 $n$ 개를 서로 공격하지 않도록 놓는다. +- 스도쿠: $9 \times 9$ 격자에 $1$ ~ $9$를 넣되, 각 행과 열 및 각 $3 \times 3$ 부분 격자에서 숫자가 중복되지 않게 한다. +- 그래프 색칠 문제: 무방향 그래프의 각 정점을 가능한 한 적은 색으로 칠하되, 인접한 정점은 서로 다른 색을 갖게 한다. + +**조합 최적화 문제**: 조합 공간에서 특정 조건을 만족하는 최적해를 찾는 것이 목표다. + +- 0-1 배낭 문제: 가치와 무게가 있는 물건들과 배낭이 주어졌을 때, 용량 제한 안에서 총가치가 최대가 되도록 물건을 고른다. +- 외판원 문제: 그래프의 한 점에서 출발하여 다른 모든 점을 정확히 한 번 방문한 뒤 출발점으로 돌아오는 최단 경로를 구한다. +- 최대 클리크 문제: 무방향 그래프에서 가장 큰 완전 부분 그래프, 즉 임의의 두 정점 사이에 간선이 있는 부분 그래프를 찾는다. + +많은 조합 최적화 문제에서 백트래킹이 최적의 해결 방법은 아니라는 점에 주의하자. + +- 0-1 배낭 문제는 더 높은 시간 효율을 위해 보통 동적 계획법으로 해결한다. +- 외판원 문제는 유명한 NP-Hard 문제이며, 유전 알고리즘과 개미 군집 알고리즘 등이 자주 쓰인다. +- 최대 클리크 문제는 그래프 이론의 대표적인 문제로, 탐욕 알고리즘 같은 휴리스틱 알고리즘을 사용할 수 있다. diff --git a/ko/docs/chapter_backtracking/exercises.md b/ko/docs/chapter_backtracking/exercises.md new file mode 100644 index 000000000..65b340bc5 --- /dev/null +++ b/ko/docs/chapter_backtracking/exercises.md @@ -0,0 +1,91 @@ +--- +comments: true +--- + +# 13.6   연습 문제 + +## 13.6.1   개념 확인 + +### 1.   이 순열 알고리즘은 결과를 빠뜨리는가 + +한 백트래킹 알고리즘이 `1, 2, 3` 순서로 숫자를 시도하며 모든 순열을 생성한다. 숫자 `x`를 선택할 때마다 다음을 수행한다. + +1. 현재 경로의 끝에 `x`를 추가한다. +2. `x`를 ‘사용함’으로 표시한다. +3. 재귀 호출로 다음 위치를 채운다. + +재귀 호출이 반환된 뒤, 학생은 경로 끝의 `x`만 삭제하고 다음 숫자를 계속 시도했다. + + + +1. 알고리즘이 처음 얻는 순열은 무엇인가? 순열 6개를 모두 얻을 수 있는가? +2. 재귀 호출에서 이전 단계로 돌아가기 전에 경로 끝의 숫자를 삭제하는 것만으로 충분한가? 충분하지 않다면 무엇을 더 해야 하는지 이유와 함께 설명하라. + +??? success "참고 답안" + + 1. 처음 얻는 순열은 `[1, 2, 3]`이지만, 모든 순열을 얻을 수는 없다. 반환하면서 경로는 짧아져도 + 숫자 1, 2, 3은 여전히 모두 ‘사용함’으로 표시되어 있어 이후 분기에서 선택할 숫자가 없기 때문이다. + + 2. 충분하지 않다. 경로 끝의 `x`를 삭제한 뒤에는 `x`를 다시 ‘사용하지 않음’으로 표시해야 한다. + 현재 경로와 사용 여부 표시가 함께 탐색 상태를 나타낸다. 선택할 때 두 곳을 수정했으므로 되돌아갈 때도 두 곳을 모두 복원해야 + 다른 분기에서 `x`를 다시 선택할 수 있다. + +### 2.   숫자를 선택하는 순서가 중요한가 + +정렬된 배열 `[2, 3, 5]`와 목표 값 5가 주어지며, 각 숫자는 반복해서 선택할 수 있다. +알고리즘에서는 각 탐색 경로의 숫자가 작은 것부터 큰 것 순으로만 나타나도록 제한한다. + + + +1. 서로 다른 어떤 조합을 얻을 수 있는가? +2. 같은 숫자들을 다른 순서로 반복해서 탐색할 필요가 없는 이유는 무엇인가? ‘작은 것부터 큰 것 순’이라는 제한은 어떤 역할을 하는가? +3. 현재 경로가 `[3]`이고 2가 더 필요한 상황에서 다음 후보 숫자가 3이다. 이때 이 단계의 나머지 후보를 모두 확인하지 않고 멈출 수 있는 이유는 무엇인가? + +??? success "참고 답안" + + 1. 서로 다른 조합은 `[2, 3]`과 `[5]`다. + + 2. 이 문제에서는 `[2, 3]`과 `[3, 2]`를 같은 조합으로 취급하므로 숫자를 선택하는 순서는 답에 반영하지 않는다. + 경로의 숫자가 작은 것부터 큰 것 순으로 나타나도록 정하면 탐색 중에 `[3, 2]` 같은 중복 조합을 바로 건너뛸 수 있다. + + 3. 현재 2가 더 필요한데 후보 숫자 3은 이미 2보다 크다. 배열이 정렬되어 있으므로 + 3 뒤의 후보는 더 크기만 할 뿐이고, 모두 현재 조합에 추가할 수 없다. 따라서 이 단계의 확인을 바로 끝낼 수 있다. + +### 3.   다음 퀸은 어디에 놓을 수 있는가 + +`4 × 4` 체스판에 행별로 퀸을 놓으며, 행과 열의 인덱스는 모두 0부터 시작한다. +현재 `(0, 1)`과 `(1, 3)`에 퀸을 놓았고, 이제 인덱스 2인 행에 다음 퀸을 놓으려 한다. + + + +1. ‘같은 열’이라는 이유로 제외되는 열은 어디인가? +2. 남은 열 가운데 ‘같은 대각선’이라는 이유로 제외되는 위치는 어디인가? +3. 인덱스 2인 행에서 시도해 볼 수 있는 위치는 어디인가? + +??? success "참고 답안" + + 1. 인덱스 1과 3인 열에 이미 퀸이 있으므로 `(2, 1)`과 `(2, 3)`은 제외된다. + + 2. 남은 위치 중 `(2, 2)`는 `(1, 3)`과 같은 대각선에 있으므로 역시 제외된다. + `(2, 0)`은 이미 놓인 두 퀸 중 어느 것과도 같은 열이나 같은 대각선에 있지 않다. + + 3. 인덱스 2인 행에서 시도할 수 있는 위치는 `(2, 0)`뿐이다. + + 이 단계에서는 현재 배치가 조건에 맞는다는 것만 확인한 셈이다. 이후 체스판을 완성할 수 없다면 여전히 되돌아가 더 앞 단계의 다른 선택을 시도해야 한다. + +## 13.6.2   프로그래밍 연습 + +### 1.   중복 원소가 없는 모든 순열 + +정수 배열 `nums`에는 원소가 적어도 하나 있으며, 모든 원소가 서로 다르다. +각 원소를 한 번씩 사용하여 만들 수 있는 모든 순서를 나열하고, 각 순서를 배열로 반환하라. +결과에서 순열들이 나오는 순서는 상관없다. +백트래킹을 사용하고, 각 위치의 원소가 현재 순열에 이미 선택되었는지 불리언 배열로 기록하라. + +??? tip "풀이 힌트" + + 1. 재귀 깊이는 순열의 몇 번째 위치를 채우는 중인지를 나타낸다. + 2. 각 단계에서는 아직 사용하지 않은 원소만 시도한다. + 3. 경로 길이가 `nums`의 길이에 도달하면 경로의 복사본을 답에 추가한다. + +[LeetCode](https://leetcode.com/problems/permutations/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_backtracking/index.md b/ko/docs/chapter_backtracking/index.md new file mode 100644 index 000000000..6881a6284 --- /dev/null +++ b/ko/docs/chapter_backtracking/index.md @@ -0,0 +1,23 @@ +--- +comments: true +icon: material/map-marker-path +--- + +# 제 13 장   백트래킹 + +![백트래킹](../assets/covers/chapter_backtracking.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 우리는 미로 속 탐험가처럼 앞으로 나아가는 길에서 어려움을 만날 수 있다. + + 백트래킹의 힘은 다시 출발하고 끊임없이 시도하여, 마침내 빛으로 향하는 출구를 찾게 해 준다. + +## 이 장의 내용 + +- [13.1   백트래킹 알고리즘](backtracking_algorithm.md) +- [13.2   모든 순열 문제](permutations_problem.md) +- [13.3   부분 집합 합 문제](subset_sum_problem.md) +- [13.4   n-퀸 문제](n_queens_problem.md) +- [13.5   요약](summary.md) +- [13.6   연습 문제](exercises.md) diff --git a/ko/docs/chapter_backtracking/n_queens_problem.md b/ko/docs/chapter_backtracking/n_queens_problem.md new file mode 100644 index 000000000..724841d70 --- /dev/null +++ b/ko/docs/chapter_backtracking/n_queens_problem.md @@ -0,0 +1,754 @@ +--- +comments: true +--- + +# 13.4   n-퀸 문제 + +!!! question "문제" + + 체스 규칙에서 퀸은 같은 행, 열 또는 대각선에 있는 말을 공격할 수 있다. 퀸 $n$ 개와 $n \times n$ 크기의 체스판이 주어졌을 때, 퀸들이 서로 공격할 수 없도록 배치하는 방법을 찾아라. + +아래 그림(13-15)과 같이 $n = 4$이면 해는 두 개다. 백트래킹 관점에서 $n \times n$ 체스판의 $n^2$ 개 칸은 모든 선택지 `choices`를 제공한다. 퀸을 하나씩 놓는 동안 체스판은 계속 바뀌며, 각 시점의 체스판이 상태 `state`다. + +![4-퀸 문제의 해](n_queens_problem.assets/solution_4_queens.png){ class="animation-figure" } + +

그림 13-15   4-퀸 문제의 해

+ +아래 그림(13-16)은 세 가지 제약 조건을 보여 준다. **여러 퀸이 같은 행, 같은 열, 같은 대각선에 있으면 안 된다**. 대각선은 주대각선 방향 `\`과 부대각선 방향 `/`의 두 종류로 나뉜다. + +![n-퀸 문제의 제약 조건](n_queens_problem.assets/n_queens_constraints.png){ class="animation-figure" } + +

그림 13-16   n-퀸 문제의 제약 조건

+ +### 1.   한 행씩 배치하는 전략 + +퀸 수와 체스판의 행 수가 모두 $n$이므로 **각 행에는 퀸을 정확히 하나씩 놓아야 한다**는 사실을 쉽게 도출할 수 있다. + +따라서 첫 행부터 마지막 행까지 매 행에 퀸 하나씩 놓는 전략을 사용할 수 있다. + +아래 그림(13-17)은 4-퀸 문제에서 한 행씩 배치하는 과정이다. 공간 제약 때문에 첫 행의 탐색 가지 중 하나만 펼쳤으며, 열과 대각선 제약을 만족하지 않는 배치는 모두 가지치기했다. + +![한 행씩 배치하는 전략](n_queens_problem.assets/n_queens_placing.png){ class="animation-figure" } + +

그림 13-17   한 행씩 배치하는 전략

+ +본질적으로 **행별 배치 전략 자체가 가지치기 역할을 한다**. 같은 행에 여러 퀸이 놓이는 모든 탐색 가지를 피하기 때문이다. + +### 2.   열과 대각선 가지치기 + +열 제약을 만족하기 위해 길이 $n$의 불리언 배열 `cols`에 각 열의 퀸 존재 여부를 기록할 수 있다. 배치를 결정하기 전에 `cols`로 이미 퀸이 있는 열을 가지치기하고, 백트래킹 중 `cols`의 상태를 동적으로 갱신한다. + +!!! tip "참고" + + 행렬의 시작점은 왼쪽 위이며, 행 인덱스는 아래로 갈수록, 열 인덱스는 오른쪽으로 갈수록 커진다. + +대각선 제약은 어떻게 처리할까? 한 칸의 행과 열 인덱스를 $(row, col)$이라고 하자. 주대각선 방향의 대각선 하나를 고르면, 그 위의 모든 칸에서 행 인덱스와 열 인덱스의 차가 같다. **즉, 같은 주대각선 방향 대각선에서는 $row - col$이 일정하다**. + +따라서 두 칸이 $row_1 - col_1 = row_2 - col_2$를 만족하면 반드시 같은 주대각선 방향 대각선에 있다. 이 규칙을 이용하여 아래 그림(13-18)의 배열 `diags1`에 각 대각선의 퀸 존재 여부를 기록할 수 있다. + +마찬가지로 **같은 부대각선 방향 대각선의 모든 칸에서는 $row + col$이 일정하다**. 배열 `diags2`를 이용하여 이 방향의 제약도 처리할 수 있다. + +![열 제약과 대각선 제약 처리](n_queens_problem.assets/n_queens_cols_diagonals.png){ class="animation-figure" } + +

그림 13-18   열 제약과 대각선 제약 처리

+ +### 3.   코드 구현 + +$n$ 차 정방행렬에서 $row - col$의 범위는 $[-n + 1, n - 1]$, $row + col$의 범위는 $[0, 2n - 2]$이다. 따라서 두 방향의 대각선 수는 각각 $2n - 1$ 개이며, 배열 `diags1`과 `diags2`의 길이도 모두 $2n - 1$이다. + +=== "Python" + + ```python title="n_queens.py" + def backtrack( + row: int, + n: int, + state: list[list[str]], + res: list[list[list[str]]], + cols: list[bool], + diags1: list[bool], + diags2: list[bool], + ): + """백트래킹 알고리즘: n-퀸""" + # 모든 행에 배치했으면 해를 기록한다 + if row == n: + res.append([list(row) for row in state]) + return + # 모든 열을 순회한다 + for col in range(n): + # 해당 칸이 속한 주대각선과 부대각선을 계산한다 + diag1 = row - col + n - 1 + diag2 = row + col + # 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if not cols[col] and not diags1[diag1] and not diags2[diag2]: + # 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = "Q" + cols[col] = diags1[diag1] = diags2[diag2] = True + # 다음 행에 배치한다 + backtrack(row + 1, n, state, res, cols, diags1, diags2) + # 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = "#" + cols[col] = diags1[diag1] = diags2[diag2] = False + + def n_queens(n: int) -> list[list[list[str]]]: + """n-퀸 문제 풀기""" + # n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + state = [["#" for _ in range(n)] for _ in range(n)] + cols = [False] * n # 열에 퀸이 있는지 기록한다 + diags1 = [False] * (2 * n - 1) # 주대각선에 퀸이 있는지 기록한다 + diags2 = [False] * (2 * n - 1) # 부대각선에 퀸이 있는지 기록한다 + res = [] + backtrack(0, n, state, res, cols, diags1, diags2) + + return res + ``` + +=== "C++" + + ```cpp title="n_queens.cpp" + /* 백트래킹 알고리즘: n-퀸 */ + void backtrack(int row, int n, vector> &state, vector>> &res, vector &cols, + vector &diags1, vector &diags2) { + // 모든 행에 배치했으면 해를 기록한다 + if (row == n) { + res.push_back(state); + return; + } + // 모든 열을 순회한다 + for (int col = 0; col < n; col++) { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + int diag1 = row - col + n - 1; + int diag2 = row + col; + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if (!cols[col] && !diags1[diag1] && !diags2[diag2]) { + // 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = "Q"; + cols[col] = diags1[diag1] = diags2[diag2] = true; + // 다음 행에 배치한다 + backtrack(row + 1, n, state, res, cols, diags1, diags2); + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = "#"; + cols[col] = diags1[diag1] = diags2[diag2] = false; + } + } + } + + /* n-퀸 문제 풀기 */ + vector>> nQueens(int n) { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + vector> state(n, vector(n, "#")); + vector cols(n, false); // 열에 퀸이 있는지 기록한다 + vector diags1(2 * n - 1, false); // 주대각선에 퀸이 있는지 기록한다 + vector diags2(2 * n - 1, false); // 부대각선에 퀸이 있는지 기록한다 + vector>> res; + + backtrack(0, n, state, res, cols, diags1, diags2); + + return res; + } + ``` + +=== "Java" + + ```java title="n_queens.java" + /* 백트래킹 알고리즘: n-퀸 */ + void backtrack(int row, int n, List> state, List>> res, + boolean[] cols, boolean[] diags1, boolean[] diags2) { + // 모든 행에 배치했으면 해를 기록한다 + if (row == n) { + List> copyState = new ArrayList<>(); + for (List sRow : state) { + copyState.add(new ArrayList<>(sRow)); + } + res.add(copyState); + return; + } + // 모든 열을 순회한다 + for (int col = 0; col < n; col++) { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + int diag1 = row - col + n - 1; + int diag2 = row + col; + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if (!cols[col] && !diags1[diag1] && !diags2[diag2]) { + // 시도: 해당 칸에 퀸을 놓는다 + state.get(row).set(col, "Q"); + cols[col] = diags1[diag1] = diags2[diag2] = true; + // 다음 행에 배치한다 + backtrack(row + 1, n, state, res, cols, diags1, diags2); + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state.get(row).set(col, "#"); + cols[col] = diags1[diag1] = diags2[diag2] = false; + } + } + } + + /* n-퀸 문제 풀기 */ + List>> nQueens(int n) { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + List> state = new ArrayList<>(); + for (int i = 0; i < n; i++) { + List row = new ArrayList<>(); + for (int j = 0; j < n; j++) { + row.add("#"); + } + state.add(row); + } + boolean[] cols = new boolean[n]; // 열에 퀸이 있는지 기록한다 + boolean[] diags1 = new boolean[2 * n - 1]; // 주대각선에 퀸이 있는지 기록한다 + boolean[] diags2 = new boolean[2 * n - 1]; // 부대각선에 퀸이 있는지 기록한다 + List>> res = new ArrayList<>(); + + backtrack(0, n, state, res, cols, diags1, diags2); + + return res; + } + ``` + +=== "C#" + + ```csharp title="n_queens.cs" + /* 백트래킹 알고리즘: n-퀸 */ + void Backtrack(int row, int n, List> state, List>> res, + bool[] cols, bool[] diags1, bool[] diags2) { + // 모든 행에 배치했으면 해를 기록한다 + if (row == n) { + List> copyState = []; + foreach (List sRow in state) { + copyState.Add(new List(sRow)); + } + res.Add(copyState); + return; + } + // 모든 열을 순회한다 + for (int col = 0; col < n; col++) { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + int diag1 = row - col + n - 1; + int diag2 = row + col; + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if (!cols[col] && !diags1[diag1] && !diags2[diag2]) { + // 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = "Q"; + cols[col] = diags1[diag1] = diags2[diag2] = true; + // 다음 행에 배치한다 + Backtrack(row + 1, n, state, res, cols, diags1, diags2); + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = "#"; + cols[col] = diags1[diag1] = diags2[diag2] = false; + } + } + } + + /* n-퀸 문제 풀기 */ + List>> NQueens(int n) { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + List> state = []; + for (int i = 0; i < n; i++) { + List row = []; + for (int j = 0; j < n; j++) { + row.Add("#"); + } + state.Add(row); + } + bool[] cols = new bool[n]; // 열에 퀸이 있는지 기록한다 + bool[] diags1 = new bool[2 * n - 1]; // 주대각선에 퀸이 있는지 기록한다 + bool[] diags2 = new bool[2 * n - 1]; // 부대각선에 퀸이 있는지 기록한다 + List>> res = []; + + Backtrack(0, n, state, res, cols, diags1, diags2); + + return res; + } + ``` + +=== "Go" + + ```go title="n_queens.go" + /* 백트래킹 알고리즘: n-퀸 */ + func backtrack(row, n int, state *[][]string, res *[][][]string, cols, diags1, diags2 *[]bool) { + // 모든 행에 배치했으면 해를 기록한다 + if row == n { + newState := make([][]string, len(*state)) + for i, _ := range newState { + newState[i] = make([]string, len((*state)[0])) + copy(newState[i], (*state)[i]) + + } + *res = append(*res, newState) + return + } + // 모든 열을 순회한다 + for col := 0; col < n; col++ { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + diag1 := row - col + n - 1 + diag2 := row + col + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if !(*cols)[col] && !(*diags1)[diag1] && !(*diags2)[diag2] { + // 시도: 해당 칸에 퀸을 놓는다 + (*state)[row][col] = "Q" + (*cols)[col], (*diags1)[diag1], (*diags2)[diag2] = true, true, true + // 다음 행에 배치한다 + backtrack(row+1, n, state, res, cols, diags1, diags2) + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + (*state)[row][col] = "#" + (*cols)[col], (*diags1)[diag1], (*diags2)[diag2] = false, false, false + } + } + } + + /* n-퀸 문제 풀기 */ + func nQueens(n int) [][][]string { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + state := make([][]string, n) + for i := 0; i < n; i++ { + row := make([]string, n) + for i := 0; i < n; i++ { + row[i] = "#" + } + state[i] = row + } + // 열에 퀸이 있는지 기록한다 + cols := make([]bool, n) + diags1 := make([]bool, 2*n-1) + diags2 := make([]bool, 2*n-1) + res := make([][][]string, 0) + backtrack(0, n, &state, &res, &cols, &diags1, &diags2) + return res + } + ``` + +=== "Swift" + + ```swift title="n_queens.swift" + /* 백트래킹 알고리즘: n-퀸 */ + func backtrack(row: Int, n: Int, state: inout [[String]], res: inout [[[String]]], cols: inout [Bool], diags1: inout [Bool], diags2: inout [Bool]) { + // 모든 행에 배치했으면 해를 기록한다 + if row == n { + res.append(state) + return + } + // 모든 열을 순회한다 + for col in 0 ..< n { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + let diag1 = row - col + n - 1 + let diag2 = row + col + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if !cols[col] && !diags1[diag1] && !diags2[diag2] { + // 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = "Q" + cols[col] = true + diags1[diag1] = true + diags2[diag2] = true + // 다음 행에 배치한다 + backtrack(row: row + 1, n: n, state: &state, res: &res, cols: &cols, diags1: &diags1, diags2: &diags2) + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = "#" + cols[col] = false + diags1[diag1] = false + diags2[diag2] = false + } + } + } + + /* n-퀸 문제 풀기 */ + func nQueens(n: Int) -> [[[String]]] { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + var state = Array(repeating: Array(repeating: "#", count: n), count: n) + var cols = Array(repeating: false, count: n) // 열에 퀸이 있는지 기록한다 + var diags1 = Array(repeating: false, count: 2 * n - 1) // 주대각선에 퀸이 있는지 기록한다 + var diags2 = Array(repeating: false, count: 2 * n - 1) // 부대각선에 퀸이 있는지 기록한다 + var res: [[[String]]] = [] + + backtrack(row: 0, n: n, state: &state, res: &res, cols: &cols, diags1: &diags1, diags2: &diags2) + + return res + } + ``` + +=== "JS" + + ```javascript title="n_queens.js" + /* 백트래킹 알고리즘: n-퀸 */ + function backtrack(row, n, state, res, cols, diags1, diags2) { + // 모든 행에 배치했으면 해를 기록한다 + if (row === n) { + res.push(state.map((row) => row.slice())); + return; + } + // 모든 열을 순회한다 + for (let col = 0; col < n; col++) { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + const diag1 = row - col + n - 1; + const diag2 = row + col; + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if (!cols[col] && !diags1[diag1] && !diags2[diag2]) { + // 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = 'Q'; + cols[col] = diags1[diag1] = diags2[diag2] = true; + // 다음 행에 배치한다 + backtrack(row + 1, n, state, res, cols, diags1, diags2); + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = '#'; + cols[col] = diags1[diag1] = diags2[diag2] = false; + } + } + } + + /* n-퀸 문제 풀기 */ + function nQueens(n) { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + const state = Array.from({ length: n }, () => Array(n).fill('#')); + const cols = Array(n).fill(false); // 열에 퀸이 있는지 기록한다 + const diags1 = Array(2 * n - 1).fill(false); // 주대각선에 퀸이 있는지 기록한다 + const diags2 = Array(2 * n - 1).fill(false); // 부대각선에 퀸이 있는지 기록한다 + const res = []; + + backtrack(0, n, state, res, cols, diags1, diags2); + return res; + } + ``` + +=== "TS" + + ```typescript title="n_queens.ts" + /* 백트래킹 알고리즘: n-퀸 */ + function backtrack( + row: number, + n: number, + state: string[][], + res: string[][][], + cols: boolean[], + diags1: boolean[], + diags2: boolean[] + ): void { + // 모든 행에 배치했으면 해를 기록한다 + if (row === n) { + res.push(state.map((row) => row.slice())); + return; + } + // 모든 열을 순회한다 + for (let col = 0; col < n; col++) { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + const diag1 = row - col + n - 1; + const diag2 = row + col; + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if (!cols[col] && !diags1[diag1] && !diags2[diag2]) { + // 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = 'Q'; + cols[col] = diags1[diag1] = diags2[diag2] = true; + // 다음 행에 배치한다 + backtrack(row + 1, n, state, res, cols, diags1, diags2); + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = '#'; + cols[col] = diags1[diag1] = diags2[diag2] = false; + } + } + } + + /* n-퀸 문제 풀기 */ + function nQueens(n: number): string[][][] { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + const state = Array.from({ length: n }, () => Array(n).fill('#')); + const cols = Array(n).fill(false); // 열에 퀸이 있는지 기록한다 + const diags1 = Array(2 * n - 1).fill(false); // 주대각선에 퀸이 있는지 기록한다 + const diags2 = Array(2 * n - 1).fill(false); // 부대각선에 퀸이 있는지 기록한다 + const res: string[][][] = []; + + backtrack(0, n, state, res, cols, diags1, diags2); + return res; + } + ``` + +=== "Dart" + + ```dart title="n_queens.dart" + /* 백트래킹 알고리즘: n-퀸 */ + void backtrack( + int row, + int n, + List> state, + List>> res, + List cols, + List diags1, + List diags2, + ) { + // 모든 행에 배치했으면 해를 기록한다 + if (row == n) { + List> copyState = []; + for (List sRow in state) { + copyState.add(List.from(sRow)); + } + res.add(copyState); + return; + } + // 모든 열을 순회한다 + for (int col = 0; col < n; col++) { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + int diag1 = row - col + n - 1; + int diag2 = row + col; + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if (!cols[col] && !diags1[diag1] && !diags2[diag2]) { + // 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = "Q"; + cols[col] = true; + diags1[diag1] = true; + diags2[diag2] = true; + // 다음 행에 배치한다 + backtrack(row + 1, n, state, res, cols, diags1, diags2); + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = "#"; + cols[col] = false; + diags1[diag1] = false; + diags2[diag2] = false; + } + } + } + + /* n-퀸 문제 풀기 */ + List>> nQueens(int n) { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + List> state = List.generate(n, (index) => List.filled(n, "#")); + List cols = List.filled(n, false); // 열에 퀸이 있는지 기록한다 + List diags1 = List.filled(2 * n - 1, false); // 주대각선에 퀸이 있는지 기록한다 + List diags2 = List.filled(2 * n - 1, false); // 부대각선에 퀸이 있는지 기록한다 + List>> res = []; + + backtrack(0, n, state, res, cols, diags1, diags2); + + return res; + } + ``` + +=== "Rust" + + ```rust title="n_queens.rs" + /* 백트래킹 알고리즘: n-퀸 */ + fn backtrack( + row: usize, + n: usize, + state: &mut Vec>, + res: &mut Vec>>, + cols: &mut [bool], + diags1: &mut [bool], + diags2: &mut [bool], + ) { + // 모든 행에 배치했으면 해를 기록한다 + if row == n { + res.push(state.clone()); + return; + } + // 모든 열을 순회한다 + for col in 0..n { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + let diag1 = row + n - 1 - col; + let diag2 = row + col; + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if !cols[col] && !diags1[diag1] && !diags2[diag2] { + // 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = "Q".into(); + (cols[col], diags1[diag1], diags2[diag2]) = (true, true, true); + // 다음 행에 배치한다 + backtrack(row + 1, n, state, res, cols, diags1, diags2); + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = "#".into(); + (cols[col], diags1[diag1], diags2[diag2]) = (false, false, false); + } + } + } + + /* n-퀸 문제 풀기 */ + fn n_queens(n: usize) -> Vec>> { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + let mut state: Vec> = vec![vec!["#".to_string(); n]; n]; + let mut cols = vec![false; n]; // 열에 퀸이 있는지 기록한다 + let mut diags1 = vec![false; 2 * n - 1]; // 주대각선에 퀸이 있는지 기록한다 + let mut diags2 = vec![false; 2 * n - 1]; // 부대각선에 퀸이 있는지 기록한다 + let mut res: Vec>> = Vec::new(); + + backtrack( + 0, + n, + &mut state, + &mut res, + &mut cols, + &mut diags1, + &mut diags2, + ); + + res + } + ``` + +=== "C" + + ```c title="n_queens.c" + /* 백트래킹 알고리즘: n-퀸 */ + void backtrack(int row, int n, char state[MAX_SIZE][MAX_SIZE], char ***res, int *resSize, bool cols[MAX_SIZE], + bool diags1[2 * MAX_SIZE - 1], bool diags2[2 * MAX_SIZE - 1]) { + // 모든 행에 배치했으면 해를 기록한다 + if (row == n) { + res[*resSize] = (char **)malloc(sizeof(char *) * n); + for (int i = 0; i < n; ++i) { + res[*resSize][i] = (char *)malloc(sizeof(char) * (n + 1)); + strcpy(res[*resSize][i], state[i]); + } + (*resSize)++; + return; + } + // 모든 열을 순회한다 + for (int col = 0; col < n; col++) { + // 해당 칸이 속한 주대각선과 부대각선을 계산한다 + int diag1 = row - col + n - 1; + int diag2 = row + col; + // 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if (!cols[col] && !diags1[diag1] && !diags2[diag2]) { + // 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = 'Q'; + cols[col] = diags1[diag1] = diags2[diag2] = true; + // 다음 행에 배치한다 + backtrack(row + 1, n, state, res, resSize, cols, diags1, diags2); + // 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = '#'; + cols[col] = diags1[diag1] = diags2[diag2] = false; + } + } + } + + /* n-퀸 문제 풀기 */ + char ***nQueens(int n, int *returnSize) { + char state[MAX_SIZE][MAX_SIZE]; + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + for (int i = 0; i < n; ++i) { + for (int j = 0; j < n; ++j) { + state[i][j] = '#'; + } + state[i][n] = '\0'; + } + bool cols[MAX_SIZE] = {false}; // 열에 퀸이 있는지 기록한다 + bool diags1[2 * MAX_SIZE - 1] = {false}; // 주대각선에 퀸이 있는지 기록한다 + bool diags2[2 * MAX_SIZE - 1] = {false}; // 부대각선에 퀸이 있는지 기록한다 + + char ***res = (char ***)malloc(sizeof(char **) * MAX_SIZE); + *returnSize = 0; + backtrack(0, n, state, res, returnSize, cols, diags1, diags2); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="n_queens.kt" + /* 백트래킹 알고리즘: n-퀸 */ + fun backtrack( + row: Int, + n: Int, + state: MutableList>, + res: MutableList>?>, + cols: BooleanArray, + diags1: BooleanArray, + diags2: BooleanArray + ) { + // 모든 행에 배치했으면 해를 기록한다 + if (row == n) { + val copyState = mutableListOf>() + for (sRow in state) { + copyState.add(sRow.toMutableList()) + } + res.add(copyState) + return + } + // 모든 열을 순회한다 + for (col in 0..>?> { + // n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + val state = mutableListOf>() + for (i in 0..() + for (j in 0..>?>() + + backtrack(0, n, state, res, cols, diags1, diags2) + + return res + } + ``` + +=== "Ruby" + + ```ruby title="n_queens.rb" + ### 백트래킹 알고리즘: n-퀸 ### + def backtrack(row, n, state, res, cols, diags1, diags2) + # 모든 행에 배치했으면 해를 기록한다 + if row == n + res << state.map { |row| row.dup } + return + end + + # 모든 열을 순회한다 + for col in 0...n + # 해당 칸이 속한 주대각선과 부대각선을 계산한다 + diag1 = row - col + n - 1 + diag2 = row + col + # 가지치기: 해당 칸이 속한 열, 주대각선, 부대각선에 퀸이 있으면 안 된다 + if !cols[col] && !diags1[diag1] && !diags2[diag2] + # 시도: 해당 칸에 퀸을 놓는다 + state[row][col] = "Q" + cols[col] = diags1[diag1] = diags2[diag2] = true + # 다음 행에 배치한다 + backtrack(row + 1, n, state, res, cols, diags1, diags2) + # 되돌리기: 해당 칸을 빈칸으로 되돌린다 + state[row][col] = "#" + cols[col] = diags1[diag1] = diags2[diag2] = false + end + end + end + + ### n-퀸 문제 풀기 ### + def n_queens(n) + # n*n 크기의 체스판을 초기화한다. 'Q'는 퀸, '#'은 빈칸을 나타낸다 + state = Array.new(n) { Array.new(n, "#") } + cols = Array.new(n, false) # 열에 퀸이 있는지 기록한다 + diags1 = Array.new(2 * n - 1, false) # 주대각선에 퀸이 있는지 기록한다 + diags2 = Array.new(2 * n - 1, false) # 부대각선에 퀸이 있는지 기록한다 + res = [] + backtrack(0, n, state, res, cols, diags1, diags2) + + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +행별로 $n$ 번 배치할 때 열 제약을 고려하면 첫 행부터 마지막 행까지 선택지 수가 $n$, $n-1$, $\dots$, $2$, $1$이므로 $O(n!)$ 시간이 걸린다. 해를 기록할 때 행렬 `state`를 복사하여 `res`에 추가해야 하며, 복사에는 $O(n^2)$ 시간이 든다. 따라서 **전체 시간 복잡도는 $O(n! \cdot n^2)$이다**. 실제로는 대각선 제약에 따른 가지치기도 탐색 공간을 크게 줄이므로 위 복잡도보다 탐색 효율이 좋은 경우가 많다. + +배열 `state`는 $O(n^2)$ 공간을, `cols`, `diags1`, `diags2`는 각각 $O(n)$ 공간을 사용한다. 최대 재귀 깊이가 $n$이므로 스택 프레임 공간은 $O(n)$이다. 따라서 **공간 복잡도는 $O(n^2)$이다**. diff --git a/ko/docs/chapter_backtracking/permutations_problem.md b/ko/docs/chapter_backtracking/permutations_problem.md new file mode 100644 index 000000000..18ed060f8 --- /dev/null +++ b/ko/docs/chapter_backtracking/permutations_problem.md @@ -0,0 +1,1108 @@ +--- +comments: true +--- + +# 13.2   모든 순열 문제 + +모든 순열 문제는 백트래킹 알고리즘의 대표적인 응용이다. 배열이나 문자열 같은 집합이 주어졌을 때 원소들의 가능한 모든 순열을 찾는 문제로 정의된다. + +아래 표(13-2)에는 입력 배열과 그에 대응하는 모든 순열의 예시가 나와 있다. + +

표 13-2   모든 순열의 예시

+ +
+ +| 입력 배열 | 모든 순열 | +| :---------- | :----------------------------------------------------------------- | +| $[1]$ | $[1]$ | +| $[1, 2]$ | $[1, 2], [2, 1]$ | +| $[1, 2, 3]$ | $[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]$ | + +
+ +## 13.2.1   값이 같은 원소가 없는 경우 + +!!! question "문제" + + 중복 원소가 없는 정수 배열을 입력받아 가능한 모든 순열을 반환하라. + +백트래킹 관점에서는 **순열을 만드는 과정을 연속된 선택의 결과로 볼 수 있다**. 입력 배열이 $[1, 2, 3]$이고 $1$, $3$, $2$ 순서로 선택하면 순열 $[1, 3, 2]$를 얻는다. 되돌리기는 선택 하나를 취소한 뒤 다른 선택을 계속 시도한다는 뜻이다. + +백트래킹 코드에서 후보 집합 `choices`는 입력 배열의 모든 원소이고, 상태 `state`는 지금까지 선택한 원소들이다. 각 원소는 한 번만 선택할 수 있으므로 **`state` 안의 원소는 모두 중복되지 않아야 한다**. + +아래 그림(13-5)과 같이 탐색 과정을 재귀 트리로 펼칠 수 있다. 각 노드는 현재 상태 `state`를 나타낸다. 루트에서 시작해 세 차례 선택하면 리프에 도달하며, 각 리프 노드는 순열 하나에 대응한다. + +![모든 순열의 재귀 트리](permutations_problem.assets/permutations_i.png){ class="animation-figure" } + +

그림 13-5   모든 순열의 재귀 트리

+ +### 1.   중복 선택 가지치기 + +각 원소를 한 번만 선택하도록 불리언 배열 `selected`를 도입하자. `selected[i]`는 `choices[i]`를 이미 선택했는지 나타내며, 이를 바탕으로 다음과 같이 가지치기한다. + +- `choices[i]`를 선택하면 `selected[i]`를 $\text{True}$로 설정하여 이미 선택했음을 표시한다. +- 선택지 리스트 `choices`를 순회할 때 이미 선택한 모든 노드를 건너뛰어 가지치기한다. + +아래 그림(13-6)처럼 첫 번째에 1, 두 번째에 3, 세 번째에 2를 선택한다면, 두 번째에서는 원소 1의 가지를, 세 번째에서는 원소 1과 3의 가지를 잘라 내야 한다. + +![모든 순열의 가지치기 예시](permutations_problem.assets/permutations_i_pruning.png){ class="animation-figure" } + +

그림 13-6   모든 순열의 가지치기 예시

+ +위 그림(13-6)에서 이 가지치기가 탐색 공간의 크기를 $O(n^n)$에서 $O(n!)$로 줄이는 것을 볼 수 있다. + +### 2.   코드 구현 + +위 내용을 정리했으니 기본 틀 코드의 '빈칸을 채울' 수 있다. 전체 코드를 짧게 만들기 위해 각 함수를 따로 구현하지 않고 `backtrack()` 함수 안에 직접 작성한다. + +=== "Python" + + ```python title="permutations_i.py" + def backtrack( + state: list[int], choices: list[int], selected: list[bool], res: list[list[int]] + ): + """백트래킹 알고리즘: 모든 순열 I""" + # 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if len(state) == len(choices): + res.append(list(state)) + return + # 모든 선택지를 순회한다 + for i, choice in enumerate(choices): + # 가지치기: 원소를 중복하여 선택할 수 없다 + if not selected[i]: + # 시도: 선택하고 상태를 갱신한다 + selected[i] = True + state.append(choice) + # 다음 선택을 진행한다 + backtrack(state, choices, selected, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = False + state.pop() + + def permutations_i(nums: list[int]) -> list[list[int]]: + """모든 순열 I""" + res = [] + backtrack(state=[], choices=nums, selected=[False] * len(nums), res=res) + return res + ``` + +=== "C++" + + ```cpp title="permutations_i.cpp" + /* 백트래킹 알고리즘: 모든 순열 I */ + void backtrack(vector &state, const vector &choices, vector &selected, vector> &res) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.size() == choices.size()) { + res.push_back(state); + return; + } + // 모든 선택지를 순회한다 + for (int i = 0; i < choices.size(); i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없다 + if (!selected[i]) { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true; + state.push_back(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.pop_back(); + } + } + } + + /* 모든 순열 I */ + vector> permutationsI(vector nums) { + vector state; + vector selected(nums.size(), false); + vector> res; + backtrack(state, nums, selected, res); + return res; + } + ``` + +=== "Java" + + ```java title="permutations_i.java" + /* 백트래킹 알고리즘: 모든 순열 I */ + void backtrack(List state, int[] choices, boolean[] selected, List> res) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.size() == choices.length) { + res.add(new ArrayList(state)); + return; + } + // 모든 선택지를 순회한다 + for (int i = 0; i < choices.length; i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없다 + if (!selected[i]) { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true; + state.add(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.remove(state.size() - 1); + } + } + } + + /* 모든 순열 I */ + List> permutationsI(int[] nums) { + List> res = new ArrayList>(); + backtrack(new ArrayList(), nums, new boolean[nums.length], res); + return res; + } + ``` + +=== "C#" + + ```csharp title="permutations_i.cs" + /* 백트래킹 알고리즘: 모든 순열 I */ + void Backtrack(List state, int[] choices, bool[] selected, List> res) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.Count == choices.Length) { + res.Add(new List(state)); + return; + } + // 모든 선택지를 순회한다 + for (int i = 0; i < choices.Length; i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없다 + if (!selected[i]) { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true; + state.Add(choice); + // 다음 선택을 진행한다 + Backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.RemoveAt(state.Count - 1); + } + } + } + + /* 모든 순열 I */ + List> PermutationsI(int[] nums) { + List> res = []; + Backtrack([], nums, new bool[nums.Length], res); + return res; + } + ``` + +=== "Go" + + ```go title="permutations_i.go" + /* 백트래킹 알고리즘: 모든 순열 I */ + func backtrackI(state *[]int, choices *[]int, selected *[]bool, res *[][]int) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if len(*state) == len(*choices) { + newState := append([]int{}, *state...) + *res = append(*res, newState) + } + // 모든 선택지를 순회한다 + for i := 0; i < len(*choices); i++ { + choice := (*choices)[i] + // 가지치기: 원소를 중복하여 선택할 수 없다 + if !(*selected)[i] { + // 시도: 선택하고 상태를 갱신한다 + (*selected)[i] = true + *state = append(*state, choice) + // 다음 선택을 진행한다 + backtrackI(state, choices, selected, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + (*selected)[i] = false + *state = (*state)[:len(*state)-1] + } + } + } + + /* 모든 순열 I */ + func permutationsI(nums []int) [][]int { + res := make([][]int, 0) + state := make([]int, 0) + selected := make([]bool, len(nums)) + backtrackI(&state, &nums, &selected, &res) + return res + } + ``` + +=== "Swift" + + ```swift title="permutations_i.swift" + /* 백트래킹 알고리즘: 모든 순열 I */ + func backtrack(state: inout [Int], choices: [Int], selected: inout [Bool], res: inout [[Int]]) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if state.count == choices.count { + res.append(state) + return + } + // 모든 선택지를 순회한다 + for (i, choice) in choices.enumerated() { + // 가지치기: 원소를 중복하여 선택할 수 없다 + if !selected[i] { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true + state.append(choice) + // 다음 선택을 진행한다 + backtrack(state: &state, choices: choices, selected: &selected, res: &res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false + state.removeLast() + } + } + } + + /* 모든 순열 I */ + func permutationsI(nums: [Int]) -> [[Int]] { + var state: [Int] = [] + var selected = Array(repeating: false, count: nums.count) + var res: [[Int]] = [] + backtrack(state: &state, choices: nums, selected: &selected, res: &res) + return res + } + ``` + +=== "JS" + + ```javascript title="permutations_i.js" + /* 백트래킹 알고리즘: 모든 순열 I */ + function backtrack(state, choices, selected, res) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.length === choices.length) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + choices.forEach((choice, i) => { + // 가지치기: 원소를 중복하여 선택할 수 없다 + if (!selected[i]) { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true; + state.push(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.pop(); + } + }); + } + + /* 모든 순열 I */ + function permutationsI(nums) { + const res = []; + backtrack([], nums, Array(nums.length).fill(false), res); + return res; + } + ``` + +=== "TS" + + ```typescript title="permutations_i.ts" + /* 백트래킹 알고리즘: 모든 순열 I */ + function backtrack( + state: number[], + choices: number[], + selected: boolean[], + res: number[][] + ): void { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.length === choices.length) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + choices.forEach((choice, i) => { + // 가지치기: 원소를 중복하여 선택할 수 없다 + if (!selected[i]) { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true; + state.push(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.pop(); + } + }); + } + + /* 모든 순열 I */ + function permutationsI(nums: number[]): number[][] { + const res: number[][] = []; + backtrack([], nums, Array(nums.length).fill(false), res); + return res; + } + ``` + +=== "Dart" + + ```dart title="permutations_i.dart" + /* 백트래킹 알고리즘: 모든 순열 I */ + void backtrack( + List state, + List choices, + List selected, + List> res, + ) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.length == choices.length) { + res.add(List.from(state)); + return; + } + // 모든 선택지를 순회한다 + for (int i = 0; i < choices.length; i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없다 + if (!selected[i]) { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true; + state.add(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.removeLast(); + } + } + } + + /* 모든 순열 I */ + List> permutationsI(List nums) { + List> res = []; + backtrack([], nums, List.filled(nums.length, false), res); + return res; + } + ``` + +=== "Rust" + + ```rust title="permutations_i.rs" + /* 백트래킹 알고리즘: 모든 순열 I */ + fn backtrack(mut state: Vec, choices: &[i32], selected: &mut [bool], res: &mut Vec>) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if state.len() == choices.len() { + res.push(state); + return; + } + // 모든 선택지를 순회한다 + for i in 0..choices.len() { + let choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없다 + if !selected[i] { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true; + state.push(choice); + // 다음 선택을 진행한다 + backtrack(state.clone(), choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.pop(); + } + } + } + + /* 모든 순열 I */ + fn permutations_i(nums: &mut [i32]) -> Vec> { + let mut res = Vec::new(); // 상태(부분 집합) + backtrack(Vec::new(), nums, &mut vec![false; nums.len()], &mut res); + res + } + ``` + +=== "C" + + ```c title="permutations_i.c" + /* 백트래킹 알고리즘: 모든 순열 I */ + void backtrack(int *state, int stateSize, int *choices, int choicesSize, bool *selected, int **res, int *resSize) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (stateSize == choicesSize) { + res[*resSize] = (int *)malloc(choicesSize * sizeof(int)); + for (int i = 0; i < choicesSize; i++) { + res[*resSize][i] = state[i]; + } + (*resSize)++; + return; + } + // 모든 선택지를 순회한다 + for (int i = 0; i < choicesSize; i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없다 + if (!selected[i]) { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true; + state[stateSize] = choice; + // 다음 선택을 진행한다 + backtrack(state, stateSize + 1, choices, choicesSize, selected, res, resSize); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + } + } + } + + /* 모든 순열 I */ + int **permutationsI(int *nums, int numsSize, int *returnSize) { + int *state = (int *)malloc(numsSize * sizeof(int)); + bool *selected = (bool *)malloc(numsSize * sizeof(bool)); + for (int i = 0; i < numsSize; i++) { + selected[i] = false; + } + int **res = (int **)malloc(MAX_SIZE * sizeof(int *)); + *returnSize = 0; + + backtrack(state, 0, nums, numsSize, selected, res, returnSize); + + free(state); + free(selected); + + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="permutations_i.kt" + /* 백트래킹 알고리즘: 모든 순열 I */ + fun backtrack( + state: MutableList, + choices: IntArray, + selected: BooleanArray, + res: MutableList?> + ) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.size == choices.size) { + res.add(state.toMutableList()) + return + } + // 모든 선택지를 순회한다 + for (i in choices.indices) { + val choice = choices[i] + // 가지치기: 원소를 중복하여 선택할 수 없다 + if (!selected[i]) { + // 시도: 선택하고 상태를 갱신한다 + selected[i] = true + state.add(choice) + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false + state.removeAt(state.size - 1) + } + } + } + + /* 모든 순열 I */ + fun permutationsI(nums: IntArray): MutableList?> { + val res = mutableListOf?>() + backtrack(mutableListOf(), nums, BooleanArray(nums.size), res) + return res + } + ``` + +=== "Ruby" + + ```ruby title="permutations_i.rb" + ### 백트래킹 알고리즘: 모든 순열 I ### + def backtrack(state, choices, selected, res) + # 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if state.length == choices.length + res << state.dup + return + end + + # 모든 선택지를 순회한다 + choices.each_with_index do |choice, i| + # 가지치기: 원소를 중복하여 선택할 수 없다 + unless selected[i] + # 시도: 선택하고 상태를 갱신한다 + selected[i] = true + state << choice + # 다음 선택을 진행한다 + backtrack(state, choices, selected, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false + state.pop + end + end + end + + ### 모든 순열 I ### + def permutations_i(nums) + res = [] + backtrack([], nums, Array.new(nums.length, false), res) + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 13.2.2   값이 같은 원소가 있는 경우 + +!!! question "문제" + + **중복 원소가 있을 수 있는** 정수 배열을 입력받아 중복되지 않는 모든 순열을 반환하라. + +입력 배열이 $[1, 1, 2]$라고 하자. 두 원소 $1$을 쉽게 구분하기 위해 두 번째 $1$을 $\hat{1}$로 표시한다. + +아래 그림(13-7)과 같이 앞의 방법으로 만든 순열의 절반은 중복된다. + +![중복된 순열](permutations_problem.assets/permutations_ii.png){ class="animation-figure" } + +

그림 13-7   중복된 순열

+ +중복된 순열은 어떻게 제거할까? 가장 직접적으로는 해시 집합을 이용해 결과에서 중복을 제거할 수 있다. 하지만 이 방법은 충분히 간결하지 않다. **중복된 순열을 만드는 탐색 가지는 불필요하므로 미리 알아내어 잘라 내야 하며**, 그러면 알고리즘의 효율을 더 높일 수 있다. + +### 1.   같은 값의 원소 가지치기 + +아래 그림(13-8)에서 첫 번째 선택으로 $1$을 고르든 $\hat{1}$을 고르든 같다. 두 선택 아래에서 만들어지는 모든 순열이 중복되므로 $\hat{1}$을 가지치기해야 한다. + +마찬가지로 첫 번째에 $2$를 선택한 뒤에도 두 번째의 $1$과 $\hat{1}$이 중복 가지를 만들므로 두 번째의 $\hat{1}$도 잘라 내야 한다. + +본질적으로 **한 차례의 선택에서 값이 같은 여러 원소를 한 번만 선택하도록 보장하는 것**이 목표다. + +![중복된 순열 가지치기](permutations_problem.assets/permutations_ii_pruning.png){ class="animation-figure" } + +

그림 13-8   중복된 순열 가지치기

+ +### 2.   코드 구현 + +앞 문제의 코드를 바탕으로 매 차례의 선택에서 해시 집합 `duplicated`를 만들어 이미 시도한 원소를 기록하고 중복 원소를 가지치기한다. + +=== "Python" + + ```python title="permutations_ii.py" + def backtrack( + state: list[int], choices: list[int], selected: list[bool], res: list[list[int]] + ): + """백트래킹 알고리즘: 모든 순열 II""" + # 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if len(state) == len(choices): + res.append(list(state)) + return + # 모든 선택지를 순회한다 + duplicated = set[int]() + for i, choice in enumerate(choices): + # 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if not selected[i] and choice not in duplicated: + # 시도: 선택하고 상태를 갱신한다 + duplicated.add(choice) # 선택한 원소의 값을 기록한다 + selected[i] = True + state.append(choice) + # 다음 선택을 진행한다 + backtrack(state, choices, selected, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = False + state.pop() + + def permutations_ii(nums: list[int]) -> list[list[int]]: + """모든 순열 II""" + res = [] + backtrack(state=[], choices=nums, selected=[False] * len(nums), res=res) + return res + ``` + +=== "C++" + + ```cpp title="permutations_ii.cpp" + /* 백트래킹 알고리즘: 모든 순열 II */ + void backtrack(vector &state, const vector &choices, vector &selected, vector> &res) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.size() == choices.size()) { + res.push_back(state); + return; + } + // 모든 선택지를 순회한다 + unordered_set duplicated; + for (int i = 0; i < choices.size(); i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if (!selected[i] && duplicated.find(choice) == duplicated.end()) { + // 시도: 선택하고 상태를 갱신한다 + duplicated.emplace(choice); // 선택한 원소의 값을 기록한다 + selected[i] = true; + state.push_back(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.pop_back(); + } + } + } + + /* 모든 순열 II */ + vector> permutationsII(vector nums) { + vector state; + vector selected(nums.size(), false); + vector> res; + backtrack(state, nums, selected, res); + return res; + } + ``` + +=== "Java" + + ```java title="permutations_ii.java" + /* 백트래킹 알고리즘: 모든 순열 II */ + void backtrack(List state, int[] choices, boolean[] selected, List> res) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.size() == choices.length) { + res.add(new ArrayList(state)); + return; + } + // 모든 선택지를 순회한다 + Set duplicated = new HashSet(); + for (int i = 0; i < choices.length; i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if (!selected[i] && !duplicated.contains(choice)) { + // 시도: 선택하고 상태를 갱신한다 + duplicated.add(choice); // 선택한 원소의 값을 기록한다 + selected[i] = true; + state.add(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.remove(state.size() - 1); + } + } + } + + /* 모든 순열 II */ + List> permutationsII(int[] nums) { + List> res = new ArrayList>(); + backtrack(new ArrayList(), nums, new boolean[nums.length], res); + return res; + } + ``` + +=== "C#" + + ```csharp title="permutations_ii.cs" + /* 백트래킹 알고리즘: 모든 순열 II */ + void Backtrack(List state, int[] choices, bool[] selected, List> res) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.Count == choices.Length) { + res.Add(new List(state)); + return; + } + // 모든 선택지를 순회한다 + HashSet duplicated = []; + for (int i = 0; i < choices.Length; i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if (!selected[i] && !duplicated.Contains(choice)) { + // 시도: 선택하고 상태를 갱신한다 + duplicated.Add(choice); // 선택한 원소의 값을 기록한다 + selected[i] = true; + state.Add(choice); + // 다음 선택을 진행한다 + Backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.RemoveAt(state.Count - 1); + } + } + } + + /* 모든 순열 II */ + List> PermutationsII(int[] nums) { + List> res = []; + Backtrack([], nums, new bool[nums.Length], res); + return res; + } + ``` + +=== "Go" + + ```go title="permutations_ii.go" + /* 백트래킹 알고리즘: 모든 순열 II */ + func backtrackII(state *[]int, choices *[]int, selected *[]bool, res *[][]int) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if len(*state) == len(*choices) { + newState := append([]int{}, *state...) + *res = append(*res, newState) + } + // 모든 선택지를 순회한다 + duplicated := make(map[int]struct{}, 0) + for i := 0; i < len(*choices); i++ { + choice := (*choices)[i] + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if _, ok := duplicated[choice]; !ok && !(*selected)[i] { + // 시도: 선택하고 상태를 갱신한다 + // 선택한 원소의 값을 기록한다 + duplicated[choice] = struct{}{} + (*selected)[i] = true + *state = append(*state, choice) + // 다음 선택을 진행한다 + backtrackII(state, choices, selected, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + (*selected)[i] = false + *state = (*state)[:len(*state)-1] + } + } + } + + /* 모든 순열 II */ + func permutationsII(nums []int) [][]int { + res := make([][]int, 0) + state := make([]int, 0) + selected := make([]bool, len(nums)) + backtrackII(&state, &nums, &selected, &res) + return res + } + ``` + +=== "Swift" + + ```swift title="permutations_ii.swift" + /* 백트래킹 알고리즘: 모든 순열 II */ + func backtrack(state: inout [Int], choices: [Int], selected: inout [Bool], res: inout [[Int]]) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if state.count == choices.count { + res.append(state) + return + } + // 모든 선택지를 순회한다 + var duplicated: Set = [] + for (i, choice) in choices.enumerated() { + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if !selected[i], !duplicated.contains(choice) { + // 시도: 선택하고 상태를 갱신한다 + duplicated.insert(choice) // 선택한 원소의 값을 기록한다 + selected[i] = true + state.append(choice) + // 다음 선택을 진행한다 + backtrack(state: &state, choices: choices, selected: &selected, res: &res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false + state.removeLast() + } + } + } + + /* 모든 순열 II */ + func permutationsII(nums: [Int]) -> [[Int]] { + var state: [Int] = [] + var selected = Array(repeating: false, count: nums.count) + var res: [[Int]] = [] + backtrack(state: &state, choices: nums, selected: &selected, res: &res) + return res + } + ``` + +=== "JS" + + ```javascript title="permutations_ii.js" + /* 백트래킹 알고리즘: 모든 순열 II */ + function backtrack(state, choices, selected, res) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.length === choices.length) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + const duplicated = new Set(); + choices.forEach((choice, i) => { + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if (!selected[i] && !duplicated.has(choice)) { + // 시도: 선택하고 상태를 갱신한다 + duplicated.add(choice); // 선택한 원소의 값을 기록한다 + selected[i] = true; + state.push(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.pop(); + } + }); + } + + /* 모든 순열 II */ + function permutationsII(nums) { + const res = []; + backtrack([], nums, Array(nums.length).fill(false), res); + return res; + } + ``` + +=== "TS" + + ```typescript title="permutations_ii.ts" + /* 백트래킹 알고리즘: 모든 순열 II */ + function backtrack( + state: number[], + choices: number[], + selected: boolean[], + res: number[][] + ): void { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.length === choices.length) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + const duplicated = new Set(); + choices.forEach((choice, i) => { + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if (!selected[i] && !duplicated.has(choice)) { + // 시도: 선택하고 상태를 갱신한다 + duplicated.add(choice); // 선택한 원소의 값을 기록한다 + selected[i] = true; + state.push(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.pop(); + } + }); + } + + /* 모든 순열 II */ + function permutationsII(nums: number[]): number[][] { + const res: number[][] = []; + backtrack([], nums, Array(nums.length).fill(false), res); + return res; + } + ``` + +=== "Dart" + + ```dart title="permutations_ii.dart" + /* 백트래킹 알고리즘: 모든 순열 II */ + void backtrack( + List state, + List choices, + List selected, + List> res, + ) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.length == choices.length) { + res.add(List.from(state)); + return; + } + // 모든 선택지를 순회한다 + Set duplicated = {}; + for (int i = 0; i < choices.length; i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if (!selected[i] && !duplicated.contains(choice)) { + // 시도: 선택하고 상태를 갱신한다 + duplicated.add(choice); // 선택한 원소의 값을 기록한다 + selected[i] = true; + state.add(choice); + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.removeLast(); + } + } + } + + /* 모든 순열 II */ + List> permutationsII(List nums) { + List> res = []; + backtrack([], nums, List.filled(nums.length, false), res); + return res; + } + ``` + +=== "Rust" + + ```rust title="permutations_ii.rs" + /* 백트래킹 알고리즘: 모든 순열 II */ + fn backtrack(mut state: Vec, choices: &[i32], selected: &mut [bool], res: &mut Vec>) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if state.len() == choices.len() { + res.push(state); + return; + } + // 모든 선택지를 순회한다 + let mut duplicated = HashSet::::new(); + for i in 0..choices.len() { + let choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if !selected[i] && !duplicated.contains(&choice) { + // 시도: 선택하고 상태를 갱신한다 + duplicated.insert(choice); // 선택한 원소의 값을 기록한다 + selected[i] = true; + state.push(choice); + // 다음 선택을 진행한다 + backtrack(state.clone(), choices, selected, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + state.pop(); + } + } + } + + /* 모든 순열 II */ + fn permutations_ii(nums: &mut [i32]) -> Vec> { + let mut res = Vec::new(); + backtrack(Vec::new(), nums, &mut vec![false; nums.len()], &mut res); + res + } + ``` + +=== "C" + + ```c title="permutations_ii.c" + /* 백트래킹 알고리즘: 모든 순열 II */ + void backtrack(int *state, int stateSize, int *choices, int choicesSize, bool *selected, int **res, int *resSize) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (stateSize == choicesSize) { + res[*resSize] = (int *)malloc(choicesSize * sizeof(int)); + for (int i = 0; i < choicesSize; i++) { + res[*resSize][i] = state[i]; + } + (*resSize)++; + return; + } + // 모든 선택지를 순회한다 + bool duplicated[MAX_SIZE] = {false}; + for (int i = 0; i < choicesSize; i++) { + int choice = choices[i]; + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if (!selected[i] && !duplicated[choice]) { + // 시도: 선택하고 상태를 갱신한다 + duplicated[choice] = true; // 선택한 원소의 값을 기록한다 + selected[i] = true; + state[stateSize] = choice; + // 다음 선택을 진행한다 + backtrack(state, stateSize + 1, choices, choicesSize, selected, res, resSize); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false; + } + } + } + + /* 모든 순열 II */ + int **permutationsII(int *nums, int numsSize, int *returnSize) { + int *state = (int *)malloc(numsSize * sizeof(int)); + bool *selected = (bool *)malloc(numsSize * sizeof(bool)); + for (int i = 0; i < numsSize; i++) { + selected[i] = false; + } + int **res = (int **)malloc(MAX_SIZE * sizeof(int *)); + *returnSize = 0; + + backtrack(state, 0, nums, numsSize, selected, res, returnSize); + + free(state); + free(selected); + + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="permutations_ii.kt" + /* 백트래킹 알고리즘: 모든 순열 II */ + fun backtrack( + state: MutableList, + choices: IntArray, + selected: BooleanArray, + res: MutableList?> + ) { + // 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if (state.size == choices.size) { + res.add(state.toMutableList()) + return + } + // 모든 선택지를 순회한다 + val duplicated = HashSet() + for (i in choices.indices) { + val choice = choices[i] + // 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if (!selected[i] && !duplicated.contains(choice)) { + // 시도: 선택하고 상태를 갱신한다 + duplicated.add(choice) // 선택한 원소의 값을 기록한다 + selected[i] = true + state.add(choice) + // 다음 선택을 진행한다 + backtrack(state, choices, selected, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false + state.removeAt(state.size - 1) + } + } + } + + /* 모든 순열 II */ + fun permutationsII(nums: IntArray): MutableList?> { + val res = mutableListOf?>() + backtrack(mutableListOf(), nums, BooleanArray(nums.size), res) + return res + } + ``` + +=== "Ruby" + + ```ruby title="permutations_ii.rb" + ### 백트래킹 알고리즘: 모든 순열 II ### + def backtrack(state, choices, selected, res) + # 상태의 길이가 원소 개수와 같으면 해를 기록한다 + if state.length == choices.length + res << state.dup + return + end + + # 모든 선택지를 순회한다 + duplicated = Set.new + choices.each_with_index do |choice, i| + # 가지치기: 원소를 중복하여 선택할 수 없으며, 값이 같은 원소도 중복하여 선택할 수 없다 + if !selected[i] && !duplicated.include?(choice) + # 시도: 선택하고 상태를 갱신한다 + duplicated.add(choice) + selected[i] = true + state << choice + # 다음 선택을 진행한다 + backtrack(state, choices, selected, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + selected[i] = false + state.pop + end + end + end + + ### 모든 순열 II ### + def permutations_ii(nums) + res = [] + backtrack([], nums, Array.new(nums.length, false), res) + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +모든 원소가 서로 다르다면 $n$ 개 원소의 순열은 $n!$ 가지(팩토리얼)다. 결과를 기록할 때마다 길이 $n$의 리스트를 복사하는 데 $O(n)$ 시간이 걸린다. **따라서 시간 복잡도는 $O(n!n)$이다**. + +최대 재귀 깊이는 $n$이므로 $O(n)$의 스택 프레임 공간을 사용한다. `selected`는 $O(n)$ 공간을 사용한다. 동시에 최대 $n$ 개의 `duplicated`가 존재하여 $O(n^2)$ 공간을 사용한다. **따라서 공간 복잡도는 $O(n^2)$이다**. + +### 3.   두 가지 가지치기 비교 + +`selected`와 `duplicated`는 모두 가지치기에 쓰이지만 목적이 다르다. + +- **중복 선택 가지치기**: 전체 탐색 과정에 `selected`는 하나만 존재한다. 현재 상태에 어떤 원소가 포함되어 있는지를 기록하여 같은 원소가 `state`에 중복 등장하지 않게 한다. +- **같은 값의 원소 가지치기**: 매 차례의 선택(호출된 각 `backtrack` 함수)마다 `duplicated`가 하나씩 있다. 이번 순회(`for` 반복문)에서 이미 선택한 원소를 기록하여 같은 값의 원소를 한 번만 선택하게 한다. + +아래 그림(13-9)은 두 가지 가지치기 조건이 적용되는 범위를 보여 준다. 각 트리 노드는 선택 하나를 나타내며, 루트에서 리프까지의 경로에 있는 노드들이 하나의 순열을 이룬다. + +![두 가지 가지치기 조건의 적용 범위](permutations_problem.assets/permutations_ii_pruning_summary.png){ class="animation-figure" } + +

그림 13-9   두 가지 가지치기 조건의 적용 범위

diff --git a/ko/docs/chapter_backtracking/subset_sum_problem.md b/ko/docs/chapter_backtracking/subset_sum_problem.md new file mode 100644 index 000000000..4f9ba1954 --- /dev/null +++ b/ko/docs/chapter_backtracking/subset_sum_problem.md @@ -0,0 +1,1685 @@ +--- +comments: true +--- + +# 13.3   부분 집합 합 문제 + +## 13.3.1   중복 원소가 없는 경우 + +!!! question "문제" + + 양의 정수 배열 `nums`와 목표 양의 정수 `target`이 주어진다. 원소의 합이 `target`인 모든 조합을 찾아라. 배열에는 중복 원소가 없고, 각 원소는 여러 번 선택할 수 있다. 조합들을 리스트로 반환하되 중복 조합이 없어야 한다. + +예를 들어 입력 집합이 $\{3, 4, 5\}$이고 목표 정수가 $9$이면 해는 $\{3, 3, 3\}, \{4, 5\}$다. 다음 두 가지에 주의하자. + +- 입력 집합의 원소는 횟수 제한 없이 반복 선택할 수 있다. +- 부분 집합에서는 원소의 순서를 구분하지 않는다. 예를 들어 $\{4, 5\}$와 $\{5, 4\}$는 같은 부분 집합이다. + +### 1.   모든 순열 풀이 참고 + +모든 순열 문제처럼 부분 집합을 만드는 과정을 연속된 선택의 결과로 볼 수 있다. 선택할 때마다 '원소의 합'을 갱신하고, 합이 `target`이 되면 해당 부분 집합을 결과 리스트에 기록한다. + +모든 순열 문제와 달리 **이 문제에서는 집합의 원소를 횟수 제한 없이 선택할 수 있다**. 따라서 선택 여부를 기록하는 불리언 리스트 `selected`가 필요하지 않다. 모든 순열 코드를 조금 수정하면 다음과 같은 첫 풀이를 얻는다. + +=== "Python" + + ```python title="subset_sum_i_naive.py" + def backtrack( + state: list[int], + target: int, + total: int, + choices: list[int], + res: list[list[int]], + ): + """백트래킹 알고리즘: 부분 집합 합 I""" + # 부분 집합의 합이 target과 같으면 해를 기록한다 + if total == target: + res.append(list(state)) + return + # 모든 선택지를 순회한다 + for i in range(len(choices)): + # 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if total + choices[i] > target: + continue + # 시도: 선택하고 원소의 합 total을 갱신한다 + state.append(choices[i]) + # 다음 선택을 진행한다 + backtrack(state, target, total + choices[i], choices, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop() + + def subset_sum_i_naive(nums: list[int], target: int) -> list[list[int]]: + """부분 집합 합 I 풀기(중복 부분 집합 포함)""" + state = [] # 상태(부분 집합) + total = 0 # 부분 집합의 합 + res = [] # 결과 리스트(부분 집합 리스트) + backtrack(state, target, total, nums, res) + return res + ``` + +=== "C++" + + ```cpp title="subset_sum_i_naive.cpp" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void backtrack(vector &state, int target, int total, vector &choices, vector> &res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (total == target) { + res.push_back(state); + return; + } + // 모든 선택지를 순회한다 + for (size_t i = 0; i < choices.size(); i++) { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if (total + choices[i] > target) { + continue; + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state.push_back(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target, total + choices[i], choices, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop_back(); + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + vector> subsetSumINaive(vector &nums, int target) { + vector state; // 상태(부분 집합) + int total = 0; // 부분 집합의 합 + vector> res; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, total, nums, res); + return res; + } + ``` + +=== "Java" + + ```java title="subset_sum_i_naive.java" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void backtrack(List state, int target, int total, int[] choices, List> res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (total == target) { + res.add(new ArrayList<>(state)); + return; + } + // 모든 선택지를 순회한다 + for (int i = 0; i < choices.length; i++) { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if (total + choices[i] > target) { + continue; + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state.add(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target, total + choices[i], choices, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.remove(state.size() - 1); + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + List> subsetSumINaive(int[] nums, int target) { + List state = new ArrayList<>(); // 상태(부분 집합) + int total = 0; // 부분 집합의 합 + List> res = new ArrayList<>(); // 결과 리스트(부분 집합 리스트) + backtrack(state, target, total, nums, res); + return res; + } + ``` + +=== "C#" + + ```csharp title="subset_sum_i_naive.cs" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void Backtrack(List state, int target, int total, int[] choices, List> res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (total == target) { + res.Add(new List(state)); + return; + } + // 모든 선택지를 순회한다 + for (int i = 0; i < choices.Length; i++) { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if (total + choices[i] > target) { + continue; + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state.Add(choices[i]); + // 다음 선택을 진행한다 + Backtrack(state, target, total + choices[i], choices, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.RemoveAt(state.Count - 1); + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + List> SubsetSumINaive(int[] nums, int target) { + List state = []; // 상태(부분 집합) + int total = 0; // 부분 집합의 합 + List> res = []; // 결과 리스트(부분 집합 리스트) + Backtrack(state, target, total, nums, res); + return res; + } + ``` + +=== "Go" + + ```go title="subset_sum_i_naive.go" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + func backtrackSubsetSumINaive(total, target int, state, choices *[]int, res *[][]int) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if target == total { + newState := append([]int{}, *state...) + *res = append(*res, newState) + return + } + // 모든 선택지를 순회한다 + for i := 0; i < len(*choices); i++ { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if total+(*choices)[i] > target { + continue + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + *state = append(*state, (*choices)[i]) + // 다음 선택을 진행한다 + backtrackSubsetSumINaive(total+(*choices)[i], target, state, choices, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + *state = (*state)[:len(*state)-1] + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + func subsetSumINaive(nums []int, target int) [][]int { + state := make([]int, 0) // 상태(부분 집합) + total := 0 // 부분 집합의 합 + res := make([][]int, 0) // 결과 리스트(부분 집합 리스트) + backtrackSubsetSumINaive(total, target, &state, &nums, &res) + return res + } + ``` + +=== "Swift" + + ```swift title="subset_sum_i_naive.swift" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + func backtrack(state: inout [Int], target: Int, total: Int, choices: [Int], res: inout [[Int]]) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if total == target { + res.append(state) + return + } + // 모든 선택지를 순회한다 + for i in choices.indices { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if total + choices[i] > target { + continue + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state.append(choices[i]) + // 다음 선택을 진행한다 + backtrack(state: &state, target: target, total: total + choices[i], choices: choices, res: &res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.removeLast() + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + func subsetSumINaive(nums: [Int], target: Int) -> [[Int]] { + var state: [Int] = [] // 상태(부분 집합) + let total = 0 // 부분 집합의 합 + var res: [[Int]] = [] // 결과 리스트(부분 집합 리스트) + backtrack(state: &state, target: target, total: total, choices: nums, res: &res) + return res + } + ``` + +=== "JS" + + ```javascript title="subset_sum_i_naive.js" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + function backtrack(state, target, total, choices, res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (total === target) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + for (let i = 0; i < choices.length; i++) { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if (total + choices[i] > target) { + continue; + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state.push(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target, total + choices[i], choices, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop(); + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + function subsetSumINaive(nums, target) { + const state = []; // 상태(부분 집합) + const total = 0; // 부분 집합의 합 + const res = []; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, total, nums, res); + return res; + } + ``` + +=== "TS" + + ```typescript title="subset_sum_i_naive.ts" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + function backtrack( + state: number[], + target: number, + total: number, + choices: number[], + res: number[][] + ): void { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (total === target) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + for (let i = 0; i < choices.length; i++) { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if (total + choices[i] > target) { + continue; + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state.push(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target, total + choices[i], choices, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop(); + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + function subsetSumINaive(nums: number[], target: number): number[][] { + const state = []; // 상태(부분 집합) + const total = 0; // 부분 집합의 합 + const res = []; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, total, nums, res); + return res; + } + ``` + +=== "Dart" + + ```dart title="subset_sum_i_naive.dart" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void backtrack( + List state, + int target, + int total, + List choices, + List> res, + ) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (total == target) { + res.add(List.from(state)); + return; + } + // 모든 선택지를 순회한다 + for (int i = 0; i < choices.length; i++) { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if (total + choices[i] > target) { + continue; + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state.add(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target, total + choices[i], choices, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.removeLast(); + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + List> subsetSumINaive(List nums, int target) { + List state = []; // 상태(부분 집합) + int total = 0; // 원소의 합 + List> res = []; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, total, nums, res); + return res; + } + ``` + +=== "Rust" + + ```rust title="subset_sum_i_naive.rs" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + fn backtrack( + state: &mut Vec, + target: i32, + total: i32, + choices: &[i32], + res: &mut Vec>, + ) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if total == target { + res.push(state.clone()); + return; + } + // 모든 선택지를 순회한다 + for i in 0..choices.len() { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if total + choices[i] > target { + continue; + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state.push(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target, total + choices[i], choices, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop(); + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + fn subset_sum_i_naive(nums: &[i32], target: i32) -> Vec> { + let mut state = Vec::new(); // 상태(부분 집합) + let total = 0; // 부분 집합의 합 + let mut res = Vec::new(); // 결과 리스트(부분 집합 리스트) + backtrack(&mut state, target, total, nums, &mut res); + res + } + ``` + +=== "C" + + ```c title="subset_sum_i_naive.c" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void backtrack(int target, int total, int *choices, int choicesSize) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (total == target) { + for (int i = 0; i < stateSize; i++) { + res[resSize][i] = state[i]; + } + resColSizes[resSize++] = stateSize; + return; + } + // 모든 선택지를 순회한다 + for (int i = 0; i < choicesSize; i++) { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if (total + choices[i] > target) { + continue; + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state[stateSize++] = choices[i]; + // 다음 선택을 진행한다 + backtrack(target, total + choices[i], choices, choicesSize); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + stateSize--; + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + void subsetSumINaive(int *nums, int numsSize, int target) { + resSize = 0; // 해의 개수를 0으로 초기화 + backtrack(target, 0, nums, numsSize); + } + ``` + +=== "Kotlin" + + ```kotlin title="subset_sum_i_naive.kt" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + fun backtrack( + state: MutableList, + target: Int, + total: Int, + choices: IntArray, + res: MutableList?> + ) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (total == target) { + res.add(state.toMutableList()) + return + } + // 모든 선택지를 순회한다 + for (i in choices.indices) { + // 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + if (total + choices[i] > target) { + continue + } + // 시도: 선택하고 원소의 합 total을 갱신한다 + state.add(choices[i]) + // 다음 선택을 진행한다 + backtrack(state, target, total + choices[i], choices, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.removeAt(state.size - 1) + } + } + + /* 부분 집합 합 I 풀기(중복 부분 집합 포함) */ + fun subsetSumINaive(nums: IntArray, target: Int): MutableList?> { + val state = mutableListOf() // 상태(부분 집합) + val total = 0 // 부분 집합의 합 + val res = mutableListOf?>() // 결과 리스트(부분 집합 리스트) + backtrack(state, target, total, nums, res) + return res + } + ``` + +=== "Ruby" + + ```ruby title="subset_sum_i_naive.rb" + ### 백트래킹 알고리즘: 부분 집합 합 I ### + def backtrack(state, target, total, choices, res) + # 부분 집합의 합이 target과 같으면 해를 기록한다 + if total == target + res << state.dup + return + end + + # 모든 선택지를 순회한다 + for i in 0...choices.length + # 가지치기: 부분 집합의 합이 target을 초과하면 해당 선택을 건너뛴다 + next if total + choices[i] > target + # 시도: 선택하고 원소의 합 total을 갱신한다 + state << choices[i] + # 다음 선택을 진행한다 + backtrack(state, target, total + choices[i], choices, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop + end + end + + ### 부분 집합 합 I 풀기(중복 부분 집합 포함)### + def subset_sum_i_naive(nums, target) + state = [] # 상태(부분 집합) + total = 0 # 부분 집합의 합 + res = [] # 결과 리스트(부분 집합 리스트) + backtrack(state, target, total, nums, res) + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +위 코드에 배열 $[3, 4, 5]$와 목표 원소 $9$를 넣으면 $[3, 3, 3], [4, 5], [5, 4]$가 나온다. **합이 $9$인 모든 부분 집합을 찾았지만 $[4, 5]$와 $[5, 4]$는 중복이다**. + +탐색 과정에서는 선택 순서를 구분하지만 부분 집합에서는 구분하지 않기 때문이다. 아래 그림(13-10)에서 $4$ 다음 $5$를 고르는 것과 $5$ 다음 $4$를 고르는 것은 서로 다른 가지이지만 같은 부분 집합에 대응한다. + +![부분 집합 탐색과 범위 초과 가지치기](subset_sum_problem.assets/subset_sum_i_naive.png){ class="animation-figure" } + +

그림 13-10   부분 집합 탐색과 범위 초과 가지치기

+ +중복 부분 집합을 제거하는 **직접적인 방법은 결과 리스트에서 중복을 제거하는 것**이다. 하지만 다음 두 이유로 매우 비효율적이다. + +- 배열 원소가 많고 특히 `target`이 크면 탐색 과정에서 중복 부분 집합이 매우 많이 생긴다. +- 부분 집합(배열)이 같은지 비교하는 데 시간이 많이 든다. 먼저 배열을 정렬한 뒤 원소를 하나씩 비교해야 한다. + +### 2.   중복 부분 집합 가지치기 + +**탐색 중 가지치기로 중복을 제거해 보자**. 아래 그림(13-11)에서 중복 부분 집합은 배열 원소를 서로 다른 순서로 선택할 때 생긴다. 예를 들면 다음과 같다. + +1. 첫 번째와 두 번째에 각각 $3$과 $4$를 선택하면 이 두 원소를 포함하는 모든 부분 집합 $[3, 4, \dots]$가 만들어진다. +2. 이후 첫 번째에 $4$를 선택하면 **두 번째에서는 $3$을 건너뛰어야 한다**. 이 선택으로 만드는 $[4, 3, \dots]$는 `1.` 단계에서 만든 부분 집합과 완전히 중복되기 때문이다. + +탐색에서 각 레벨의 선택은 왼쪽부터 오른쪽 순서로 시도하므로, 오른쪽으로 갈수록 잘라 내는 가지가 많아진다. + +1. 첫 두 차례에 $3$과 $5$를 선택하여 부분 집합 $[3, 5, \dots]$를 만든다. +2. 첫 두 차례에 $4$와 $5$를 선택하여 부분 집합 $[4, 5, \dots]$를 만든다. +3. 첫 번째에 $5$를 선택한다면 **두 번째에서는 $3$과 $4$를 건너뛰어야 한다**. $[5, 3, \dots]$와 $[5, 4, \dots]$는 `1.` 단계와 `2.` 단계의 부분 집합과 완전히 중복되기 때문이다. + +![선택 순서의 차이로 생기는 중복 부분 집합](subset_sum_problem.assets/subset_sum_i_pruning.png){ class="animation-figure" } + +

그림 13-11   선택 순서의 차이로 생기는 중복 부분 집합

+ +정리하면 입력 배열이 $[x_1, x_2, \dots, x_n]$일 때 선택 순서 $[x_{i_1}, x_{i_2}, \dots, x_{i_m}]$는 $i_1 \leq i_2 \leq \dots \leq i_m$을 만족해야 한다. **이 조건을 만족하지 않는 선택 순서는 중복을 만들므로 가지치기해야 한다**. + +### 3.   코드 구현 + +이 가지치기를 위해 순회 시작점을 나타내는 변수 `start`를 초기화한다. **$x_{i}$를 선택한 뒤 다음 차례는 인덱스 $i$부터 순회하도록 설정한다**. 그러면 선택 순서가 $i_1 \leq i_2 \leq \dots \leq i_m$을 만족하여 부분 집합의 유일성을 보장한다. + +이 밖에도 다음 두 가지를 최적화했다. + +- 탐색을 시작하기 전에 배열 `nums`를 정렬한다. 선택지를 순회하다가 **부분 집합의 합이 `target`을 넘으면 반복문을 바로 종료한다**. 뒤의 원소들은 더 크므로 합도 반드시 `target`을 넘기 때문이다. +- 원소 합 변수 `total`을 없애고 **`target`에서 값을 빼는 방식으로 합을 추적한다**. `target`이 $0$이 되면 해를 기록한다. + +=== "Python" + + ```python title="subset_sum_i.py" + def backtrack( + state: list[int], target: int, choices: list[int], start: int, res: list[list[int]] + ): + """백트래킹 알고리즘: 부분 집합 합 I""" + # 부분 집합의 합이 target과 같으면 해를 기록한다 + if target == 0: + res.append(list(state)) + return + # 모든 선택지를 순회한다 + # 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for i in range(start, len(choices)): + # 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + # 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if target - choices[i] < 0: + break + # 시도: 선택하고 target, start를 갱신한다 + state.append(choices[i]) + # 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop() + + def subset_sum_i(nums: list[int], target: int) -> list[list[int]]: + """부분 집합 합 I 풀기""" + state = [] # 상태(부분 집합) + nums.sort() # nums를 정렬한다 + start = 0 # 순회 시작 위치 + res = [] # 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res) + return res + ``` + +=== "C++" + + ```cpp title="subset_sum_i.cpp" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void backtrack(vector &state, int target, vector &choices, int start, vector> &res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.push_back(state); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for (int i = start; i < choices.size(); i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 시도: 선택하고 target, start를 갱신한다 + state.push_back(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop_back(); + } + } + + /* 부분 집합 합 I 풀기 */ + vector> subsetSumI(vector &nums, int target) { + vector state; // 상태(부분 집합) + sort(nums.begin(), nums.end()); // nums를 정렬한다 + int start = 0; // 순회 시작 위치 + vector> res; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "Java" + + ```java title="subset_sum_i.java" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void backtrack(List state, int target, int[] choices, int start, List> res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.add(new ArrayList<>(state)); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for (int i = start; i < choices.length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 시도: 선택하고 target, start를 갱신한다 + state.add(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.remove(state.size() - 1); + } + } + + /* 부분 집합 합 I 풀기 */ + List> subsetSumI(int[] nums, int target) { + List state = new ArrayList<>(); // 상태(부분 집합) + Arrays.sort(nums); // nums를 정렬한다 + int start = 0; // 순회 시작 위치 + List> res = new ArrayList<>(); // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "C#" + + ```csharp title="subset_sum_i.cs" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void Backtrack(List state, int target, int[] choices, int start, List> res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.Add(new List(state)); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for (int i = start; i < choices.Length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 시도: 선택하고 target, start를 갱신한다 + state.Add(choices[i]); + // 다음 선택을 진행한다 + Backtrack(state, target - choices[i], choices, i, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.RemoveAt(state.Count - 1); + } + } + + /* 부분 집합 합 I 풀기 */ + List> SubsetSumI(int[] nums, int target) { + List state = []; // 상태(부분 집합) + Array.Sort(nums); // nums를 정렬한다 + int start = 0; // 순회 시작 위치 + List> res = []; // 결과 리스트(부분 집합 리스트) + Backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "Go" + + ```go title="subset_sum_i.go" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + func backtrackSubsetSumI(start, target int, state, choices *[]int, res *[][]int) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if target == 0 { + newState := append([]int{}, *state...) + *res = append(*res, newState) + return + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for i := start; i < len(*choices); i++ { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if target-(*choices)[i] < 0 { + break + } + // 시도: 선택하고 target, start를 갱신한다 + *state = append(*state, (*choices)[i]) + // 다음 선택을 진행한다 + backtrackSubsetSumI(i, target-(*choices)[i], state, choices, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + *state = (*state)[:len(*state)-1] + } + } + + /* 부분 집합 합 I 풀기 */ + func subsetSumI(nums []int, target int) [][]int { + state := make([]int, 0) // 상태(부분 집합) + sort.Ints(nums) // nums를 정렬한다 + start := 0 // 순회 시작 위치 + res := make([][]int, 0) // 결과 리스트(부분 집합 리스트) + backtrackSubsetSumI(start, target, &state, &nums, &res) + return res + } + ``` + +=== "Swift" + + ```swift title="subset_sum_i.swift" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + func backtrack(state: inout [Int], target: Int, choices: [Int], start: Int, res: inout [[Int]]) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if target == 0 { + res.append(state) + return + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for i in choices.indices.dropFirst(start) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if target - choices[i] < 0 { + break + } + // 시도: 선택하고 target, start를 갱신한다 + state.append(choices[i]) + // 다음 선택을 진행한다 + backtrack(state: &state, target: target - choices[i], choices: choices, start: i, res: &res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.removeLast() + } + } + + /* 부분 집합 합 I 풀기 */ + func subsetSumI(nums: [Int], target: Int) -> [[Int]] { + var state: [Int] = [] // 상태(부분 집합) + let nums = nums.sorted() // nums를 정렬한다 + let start = 0 // 순회 시작 위치 + var res: [[Int]] = [] // 결과 리스트(부분 집합 리스트) + backtrack(state: &state, target: target, choices: nums, start: start, res: &res) + return res + } + ``` + +=== "JS" + + ```javascript title="subset_sum_i.js" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + function backtrack(state, target, choices, start, res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target === 0) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for (let i = start; i < choices.length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 시도: 선택하고 target, start를 갱신한다 + state.push(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop(); + } + } + + /* 부분 집합 합 I 풀기 */ + function subsetSumI(nums, target) { + const state = []; // 상태(부분 집합) + nums.sort((a, b) => a - b); // nums를 정렬한다 + const start = 0; // 순회 시작 위치 + const res = []; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "TS" + + ```typescript title="subset_sum_i.ts" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + function backtrack( + state: number[], + target: number, + choices: number[], + start: number, + res: number[][] + ): void { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target === 0) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for (let i = start; i < choices.length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 시도: 선택하고 target, start를 갱신한다 + state.push(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop(); + } + } + + /* 부분 집합 합 I 풀기 */ + function subsetSumI(nums: number[], target: number): number[][] { + const state = []; // 상태(부분 집합) + nums.sort((a, b) => a - b); // nums를 정렬한다 + const start = 0; // 순회 시작 위치 + const res = []; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "Dart" + + ```dart title="subset_sum_i.dart" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void backtrack( + List state, + int target, + List choices, + int start, + List> res, + ) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.add(List.from(state)); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for (int i = start; i < choices.length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 시도: 선택하고 target, start를 갱신한다 + state.add(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.removeLast(); + } + } + + /* 부분 집합 합 I 풀기 */ + List> subsetSumI(List nums, int target) { + List state = []; // 상태(부분 집합) + nums.sort(); // nums를 정렬한다 + int start = 0; // 순회 시작 위치 + List> res = []; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "Rust" + + ```rust title="subset_sum_i.rs" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + fn backtrack( + state: &mut Vec, + target: i32, + choices: &[i32], + start: usize, + res: &mut Vec>, + ) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if target == 0 { + res.push(state.clone()); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for i in start..choices.len() { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if target - choices[i] < 0 { + break; + } + // 시도: 선택하고 target, start를 갱신한다 + state.push(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop(); + } + } + + /* 부분 집합 합 I 풀기 */ + fn subset_sum_i(nums: &mut [i32], target: i32) -> Vec> { + let mut state = Vec::new(); // 상태(부분 집합) + nums.sort(); // nums를 정렬한다 + let start = 0; // 순회 시작 위치 + let mut res = Vec::new(); // 결과 리스트(부분 집합 리스트) + backtrack(&mut state, target, nums, start, &mut res); + res + } + ``` + +=== "C" + + ```c title="subset_sum_i.c" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + void backtrack(int target, int *choices, int choicesSize, int start) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + for (int i = 0; i < stateSize; ++i) { + res[resSize][i] = state[i]; + } + resColSizes[resSize++] = stateSize; + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for (int i = start; i < choicesSize; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 시도: 선택하고 target, start를 갱신한다 + state[stateSize] = choices[i]; + stateSize++; + // 다음 선택을 진행한다 + backtrack(target - choices[i], choices, choicesSize, i); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + stateSize--; + } + } + + /* 부분 집합 합 I 풀기 */ + void subsetSumI(int *nums, int numsSize, int target) { + qsort(nums, numsSize, sizeof(int), cmp); // nums를 정렬한다 + int start = 0; // 순회 시작 위치 + backtrack(target, nums, numsSize, start); + } + ``` + +=== "Kotlin" + + ```kotlin title="subset_sum_i.kt" + /* 백트래킹 알고리즘: 부분 집합 합 I */ + fun backtrack( + state: MutableList, + target: Int, + choices: IntArray, + start: Int, + res: MutableList?> + ) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.add(state.toMutableList()) + return + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for (i in start..?> { + val state = mutableListOf() // 상태(부분 집합) + nums.sort() // nums를 정렬한다 + val start = 0 // 순회 시작 위치 + val res = mutableListOf?>() // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res) + return res + } + ``` + +=== "Ruby" + + ```ruby title="subset_sum_i.rb" + ### 백트래킹 알고리즘: 부분 집합 합 I ### + def backtrack(state, target, choices, start, res) + # 부분 집합의 합이 target과 같으면 해를 기록한다 + if target.zero? + res << state.dup + return + end + # 모든 선택지를 순회한다 + # 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + for i in start...choices.length + # 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + # 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + break if target - choices[i] < 0 + # 시도: 선택하고 target, start를 갱신한다 + state << choices[i] + # 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop + end + end + + ### 부분 집합 합 I 풀기 ### + def subset_sum_i(nums, target) + state = [] # 상태(부분 집합) + nums.sort! # nums를 정렬한다 + start = 0 # 순회 시작 위치 + res = [] # 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res) + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(13-12)은 배열 $[3, 4, 5]$와 목표 원소 $9$를 위 코드에 넣었을 때의 전체 백트래킹 과정이다. + +![부분 집합 합 I의 백트래킹 과정](subset_sum_problem.assets/subset_sum_i.png){ class="animation-figure" } + +

그림 13-12   부분 집합 합 I의 백트래킹 과정

+ +## 13.3.2   중복 원소가 있는 경우 + +!!! question "문제" + + 양의 정수 배열 `nums`와 목표 양의 정수 `target`이 주어진다. 원소의 합이 `target`인 모든 조합을 찾아라. **배열에는 중복 원소가 있을 수 있고, 각 원소는 한 번만 선택할 수 있다**. 조합들을 리스트로 반환하되 중복 조합이 없어야 한다. + +앞 문제와 달리 **입력 배열에 중복 원소가 있을 수 있어** 새로운 문제가 생긴다. 예를 들어 배열 $[4, \hat{4}, 5]$와 목표 원소 $9$를 기존 코드에 넣으면 $[4, 5], [\hat{4}, 5]$가 나와 부분 집합이 중복된다. + +**중복의 원인은 값이 같은 원소를 한 차례의 선택에서 여러 번 고르기 때문이다**. 아래 그림(13-13)에서 첫 차례의 세 선택지 중 두 개가 $4$여서 중복된 두 탐색 가지가 만들어지고, 같은 부분 집합을 출력한다. 마찬가지로 두 번째 차례의 두 $4$도 중복을 만든다. + +![같은 값의 원소로 생기는 중복 부분 집합](subset_sum_problem.assets/subset_sum_ii_repeat.png){ class="animation-figure" } + +

그림 13-13   같은 값의 원소로 생기는 중복 부분 집합

+ +### 1.   같은 값의 원소 가지치기 + +이를 해결하려면 **각 차례에서 같은 값의 원소를 한 번만 선택하도록 제한해야 한다**. 구현 방법은 간단하면서도 기발하다. 배열이 정렬되어 있으므로 같은 원소끼리는 인접한다. 따라서 한 차례의 선택에서 현재 원소가 왼쪽 원소와 같다면 이미 선택한 값이라는 뜻이므로 바로 건너뛴다. + +또한 **이 문제에서는 각 배열 원소를 한 번만 선택할 수 있다**. 다행히 `start`로 이 제약도 만족시킬 수 있다. $x_{i}$를 선택한 뒤 다음 차례는 인덱스 $i + 1$부터 배열의 끝을 향해 순회하도록 설정한다. 그러면 중복 부분 집합과 같은 원소의 반복 선택을 모두 막을 수 있다. + +### 2.   코드 구현 + +=== "Python" + + ```python title="subset_sum_ii.py" + def backtrack( + state: list[int], target: int, choices: list[int], start: int, res: list[list[int]] + ): + """백트래킹 알고리즘: 부분 집합 합 II""" + # 부분 집합의 합이 target과 같으면 해를 기록한다 + if target == 0: + res.append(list(state)) + return + # 모든 선택지를 순회한다 + # 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + # 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for i in range(start, len(choices)): + # 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + # 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if target - choices[i] < 0: + break + # 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if i > start and choices[i] == choices[i - 1]: + continue + # 시도: 선택하고 target, start를 갱신한다 + state.append(choices[i]) + # 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i + 1, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop() + + def subset_sum_ii(nums: list[int], target: int) -> list[list[int]]: + """부분 집합 합 II 풀기""" + state = [] # 상태(부분 집합) + nums.sort() # nums를 정렬한다 + start = 0 # 순회 시작 위치 + res = [] # 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res) + return res + ``` + +=== "C++" + + ```cpp title="subset_sum_ii.cpp" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + void backtrack(vector &state, int target, vector &choices, int start, vector> &res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.push_back(state); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for (int i = start; i < choices.size(); i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if (i > start && choices[i] == choices[i - 1]) { + continue; + } + // 시도: 선택하고 target, start를 갱신한다 + state.push_back(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i + 1, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop_back(); + } + } + + /* 부분 집합 합 II 풀기 */ + vector> subsetSumII(vector &nums, int target) { + vector state; // 상태(부분 집합) + sort(nums.begin(), nums.end()); // nums를 정렬한다 + int start = 0; // 순회 시작 위치 + vector> res; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "Java" + + ```java title="subset_sum_ii.java" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + void backtrack(List state, int target, int[] choices, int start, List> res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.add(new ArrayList<>(state)); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for (int i = start; i < choices.length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if (i > start && choices[i] == choices[i - 1]) { + continue; + } + // 시도: 선택하고 target, start를 갱신한다 + state.add(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i + 1, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.remove(state.size() - 1); + } + } + + /* 부분 집합 합 II 풀기 */ + List> subsetSumII(int[] nums, int target) { + List state = new ArrayList<>(); // 상태(부분 집합) + Arrays.sort(nums); // nums를 정렬한다 + int start = 0; // 순회 시작 위치 + List> res = new ArrayList<>(); // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "C#" + + ```csharp title="subset_sum_ii.cs" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + void Backtrack(List state, int target, int[] choices, int start, List> res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.Add(new List(state)); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for (int i = start; i < choices.Length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if (i > start && choices[i] == choices[i - 1]) { + continue; + } + // 시도: 선택하고 target, start를 갱신한다 + state.Add(choices[i]); + // 다음 선택을 진행한다 + Backtrack(state, target - choices[i], choices, i + 1, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.RemoveAt(state.Count - 1); + } + } + + /* 부분 집합 합 II 풀기 */ + List> SubsetSumII(int[] nums, int target) { + List state = []; // 상태(부분 집합) + Array.Sort(nums); // nums를 정렬한다 + int start = 0; // 순회 시작 위치 + List> res = []; // 결과 리스트(부분 집합 리스트) + Backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "Go" + + ```go title="subset_sum_ii.go" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + func backtrackSubsetSumII(start, target int, state, choices *[]int, res *[][]int) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if target == 0 { + newState := append([]int{}, *state...) + *res = append(*res, newState) + return + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for i := start; i < len(*choices); i++ { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if target-(*choices)[i] < 0 { + break + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if i > start && (*choices)[i] == (*choices)[i-1] { + continue + } + // 시도: 선택하고 target, start를 갱신한다 + *state = append(*state, (*choices)[i]) + // 다음 선택을 진행한다 + backtrackSubsetSumII(i+1, target-(*choices)[i], state, choices, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + *state = (*state)[:len(*state)-1] + } + } + + /* 부분 집합 합 II 풀기 */ + func subsetSumII(nums []int, target int) [][]int { + state := make([]int, 0) // 상태(부분 집합) + sort.Ints(nums) // nums를 정렬한다 + start := 0 // 순회 시작 위치 + res := make([][]int, 0) // 결과 리스트(부분 집합 리스트) + backtrackSubsetSumII(start, target, &state, &nums, &res) + return res + } + ``` + +=== "Swift" + + ```swift title="subset_sum_ii.swift" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + func backtrack(state: inout [Int], target: Int, choices: [Int], start: Int, res: inout [[Int]]) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if target == 0 { + res.append(state) + return + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for i in choices.indices.dropFirst(start) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if target - choices[i] < 0 { + break + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if i > start, choices[i] == choices[i - 1] { + continue + } + // 시도: 선택하고 target, start를 갱신한다 + state.append(choices[i]) + // 다음 선택을 진행한다 + backtrack(state: &state, target: target - choices[i], choices: choices, start: i + 1, res: &res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.removeLast() + } + } + + /* 부분 집합 합 II 풀기 */ + func subsetSumII(nums: [Int], target: Int) -> [[Int]] { + var state: [Int] = [] // 상태(부분 집합) + let nums = nums.sorted() // nums를 정렬한다 + let start = 0 // 순회 시작 위치 + var res: [[Int]] = [] // 결과 리스트(부분 집합 리스트) + backtrack(state: &state, target: target, choices: nums, start: start, res: &res) + return res + } + ``` + +=== "JS" + + ```javascript title="subset_sum_ii.js" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + function backtrack(state, target, choices, start, res) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target === 0) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for (let i = start; i < choices.length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if (i > start && choices[i] === choices[i - 1]) { + continue; + } + // 시도: 선택하고 target, start를 갱신한다 + state.push(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i + 1, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop(); + } + } + + /* 부분 집합 합 II 풀기 */ + function subsetSumII(nums, target) { + const state = []; // 상태(부분 집합) + nums.sort((a, b) => a - b); // nums를 정렬한다 + const start = 0; // 순회 시작 위치 + const res = []; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "TS" + + ```typescript title="subset_sum_ii.ts" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + function backtrack( + state: number[], + target: number, + choices: number[], + start: number, + res: number[][] + ): void { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target === 0) { + res.push([...state]); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for (let i = start; i < choices.length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if (i > start && choices[i] === choices[i - 1]) { + continue; + } + // 시도: 선택하고 target, start를 갱신한다 + state.push(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i + 1, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop(); + } + } + + /* 부분 집합 합 II 풀기 */ + function subsetSumII(nums: number[], target: number): number[][] { + const state = []; // 상태(부분 집합) + nums.sort((a, b) => a - b); // nums를 정렬한다 + const start = 0; // 순회 시작 위치 + const res = []; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "Dart" + + ```dart title="subset_sum_ii.dart" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + void backtrack( + List state, + int target, + List choices, + int start, + List> res, + ) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.add(List.from(state)); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for (int i = start; i < choices.length; i++) { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if (target - choices[i] < 0) { + break; + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if (i > start && choices[i] == choices[i - 1]) { + continue; + } + // 시도: 선택하고 target, start를 갱신한다 + state.add(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i + 1, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.removeLast(); + } + } + + /* 부분 집합 합 II 풀기 */ + List> subsetSumII(List nums, int target) { + List state = []; // 상태(부분 집합) + nums.sort(); // nums를 정렬한다 + int start = 0; // 순회 시작 위치 + List> res = []; // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res); + return res; + } + ``` + +=== "Rust" + + ```rust title="subset_sum_ii.rs" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + fn backtrack( + state: &mut Vec, + target: i32, + choices: &[i32], + start: usize, + res: &mut Vec>, + ) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if target == 0 { + res.push(state.clone()); + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for i in start..choices.len() { + // 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + // 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + if target - choices[i] < 0 { + break; + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if i > start && choices[i] == choices[i - 1] { + continue; + } + // 시도: 선택하고 target, start를 갱신한다 + state.push(choices[i]); + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i + 1, res); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop(); + } + } + + /* 부분 집합 합 II 풀기 */ + fn subset_sum_ii(nums: &mut [i32], target: i32) -> Vec> { + let mut state = Vec::new(); // 상태(부분 집합) + nums.sort(); // nums를 정렬한다 + let start = 0; // 순회 시작 위치 + let mut res = Vec::new(); // 결과 리스트(부분 집합 리스트) + backtrack(&mut state, target, nums, start, &mut res); + res + } + ``` + +=== "C" + + ```c title="subset_sum_ii.c" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + void backtrack(int target, int *choices, int choicesSize, int start) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + for (int i = 0; i < stateSize; i++) { + res[resSize][i] = state[i]; + } + resColSizes[resSize++] = stateSize; + return; + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for (int i = start; i < choicesSize; i++) { + // 가지치기 1: 부분 집합의 합이 target을 넘으면 바로 건너뛴다 + if (target - choices[i] < 0) { + continue; + } + // 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + if (i > start && choices[i] == choices[i - 1]) { + continue; + } + // 시도: 선택하고 target, start를 갱신한다 + state[stateSize] = choices[i]; + stateSize++; + // 다음 선택을 진행한다 + backtrack(target - choices[i], choices, choicesSize, i + 1); + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + stateSize--; + } + } + + /* 부분 집합 합 II 풀기 */ + void subsetSumII(int *nums, int numsSize, int target) { + // nums를 정렬한다 + qsort(nums, numsSize, sizeof(int), cmp); + // 백트래킹 시작 + backtrack(target, nums, numsSize, 0); + } + ``` + +=== "Kotlin" + + ```kotlin title="subset_sum_ii.kt" + /* 백트래킹 알고리즘: 부분 집합 합 II */ + fun backtrack( + state: MutableList, + target: Int, + choices: IntArray, + start: Int, + res: MutableList?> + ) { + // 부분 집합의 합이 target과 같으면 해를 기록한다 + if (target == 0) { + res.add(state.toMutableList()) + return + } + // 모든 선택지를 순회한다 + // 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + // 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for (i in start.. start && choices[i] == choices[i - 1]) { + continue + } + // 시도: 선택하고 target, start를 갱신한다 + state.add(choices[i]) + // 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i + 1, res) + // 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.removeAt(state.size - 1) + } + } + + /* 부분 집합 합 II 풀기 */ + fun subsetSumII(nums: IntArray, target: Int): MutableList?> { + val state = mutableListOf() // 상태(부분 집합) + nums.sort() // nums를 정렬한다 + val start = 0 // 순회 시작 위치 + val res = mutableListOf?>() // 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res) + return res + } + ``` + +=== "Ruby" + + ```ruby title="subset_sum_ii.rb" + ### 백트래킹 알고리즘: 부분 집합 합 II ### + def backtrack(state, target, choices, start, res) + # 부분 집합의 합이 target과 같으면 해를 기록한다 + if target.zero? + res << state.dup + return + end + + # 모든 선택지를 순회한다 + # 가지치기 2: start부터 순회하여 중복 부분 집합 생성을 방지한다 + # 가지치기 3: start부터 순회하여 같은 원소의 중복 선택을 방지한다 + for i in start...choices.length + # 가지치기 1: 부분 집합의 합이 target을 초과하면 즉시 반복문을 종료한다 + # 배열이 정렬되어 있어 뒤의 원소가 더 크므로, 부분 집합의 합이 반드시 target을 초과하기 때문이다 + break if target - choices[i] < 0 + # 가지치기 4: 해당 원소가 왼쪽 원소와 같으면 탐색 분기가 중복되므로 바로 건너뛴다 + next if i > start && choices[i] == choices[i - 1] + # 시도: 선택하고 target, start를 갱신한다 + state << choices[i] + # 다음 선택을 진행한다 + backtrack(state, target - choices[i], choices, i + 1, res) + # 되돌리기: 선택을 취소하고 이전 상태로 되돌린다 + state.pop + end + end + + ### 부분 집합 합 II 풀기 ### + def subset_sum_ii(nums, target) + state = [] # 상태(부분 집합) + nums.sort! # nums를 정렬한다 + start = 0 # 순회 시작 위치 + res = [] # 결과 리스트(부분 집합 리스트) + backtrack(state, target, nums, start, res) + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(13-14)은 배열 $[4, 4, 5]$와 목표 원소 $9$의 백트래킹 과정으로, 네 가지 가지치기를 포함한다. 그림과 코드 주석을 함께 보면서 전체 탐색 과정과 각 가지치기가 어떻게 작동하는지 이해해 보자. + +![부분 집합 합 II의 백트래킹 과정](subset_sum_problem.assets/subset_sum_ii.png){ class="animation-figure" } + +

그림 13-14   부분 집합 합 II의 백트래킹 과정

diff --git a/ko/docs/chapter_backtracking/summary.md b/ko/docs/chapter_backtracking/summary.md new file mode 100644 index 000000000..becd7e44f --- /dev/null +++ b/ko/docs/chapter_backtracking/summary.md @@ -0,0 +1,27 @@ +--- +comments: true +--- + +# 13.5   요약 + +### 1.   핵심 정리 + +- 백트래킹은 본질적으로 모든 경우를 열거하는 방법이다. 해 공간을 깊이 우선으로 순회하여 조건에 맞는 해를 찾고 기록하며, 모든 해를 찾거나 순회가 끝나면 종료한다. +- 백트래킹의 탐색 과정은 시도와 되돌리기로 이루어진다. 깊이 우선 탐색으로 여러 선택을 시도하고, 제약 조건을 만족하지 않으면 직전 선택을 취소하여 이전 상태로 돌아간 뒤 다른 선택을 계속 시도한다. 시도와 되돌리기는 서로 반대 방향의 연산이다. +- 백트래킹 문제에는 보통 여러 제약 조건이 있고, 이를 가지치기에 사용할 수 있다. 가지치기는 불필요한 탐색 가지를 일찍 종료하여 효율을 크게 높인다. +- 백트래킹은 주로 탐색 문제와 제약 만족 문제에 사용한다. 조합 최적화 문제도 해결할 수 있지만, 더 효율적이거나 더 좋은 결과를 내는 풀이가 있는 경우가 많다. +- 모든 순열 문제는 주어진 집합 원소들의 가능한 모든 순열을 찾는 것이 목표다. 배열로 각 원소의 선택 여부를 기록하고 동일 원소를 중복 선택하는 가지를 잘라 내어 각 원소를 한 번만 선택하도록 한다. +- 모든 순열 문제에서 집합에 중복 원소가 있으면 결과에 중복된 순열이 생긴다. 각 차례에서 같은 값의 원소를 한 번만 선택하도록 제한해야 하며, 보통 해시 집합으로 구현한다. +- 부분 집합 합 문제는 주어진 집합에서 합이 목표 값인 모든 부분 집합을 찾는다. 집합은 원소 순서를 구분하지 않지만 탐색은 모든 순서의 결과를 출력하므로 중복이 생긴다. 백트래킹 전에 데이터를 정렬하고 각 차례의 순회 시작점을 변수로 지정하여 중복 부분 집합을 만드는 가지를 잘라 낸다. +- 부분 집합 합 문제에서는 배열의 같은 값 원소가 중복 집합을 만들 수 있다. 배열이 정렬되어 있다는 전제를 이용해 인접 원소의 값이 같은지 확인하여 가지치기하고, 같은 값을 매 차례 한 번만 선택하도록 한다. +- $n$-퀸 문제는 $n \times n$ 체스판에 퀸 $n$ 개를 서로 공격하지 않게 배치하는 문제다. 행, 열, 주대각선 방향, 부대각선 방향의 제약이 있다. 행 제약을 만족하려고 행별 배치 전략을 사용하여 각 행에 퀸 하나를 놓는다. +- 열 제약과 대각선 제약은 비슷하게 처리한다. 열 제약은 배열에 각 열의 퀸 존재 여부를 기록하여 선택한 칸이 유효한지 판단한다. 대각선 제약은 두 배열로 각 방향 대각선의 퀸 존재 여부를 기록한다. 핵심은 같은 주대각선(부대각선) 방향 대각선의 칸들이 만족하는 행과 열 인덱스의 규칙을 찾는 것이다. + +### 2.   Q & A + +**Q**: 백트래킹과 재귀의 관계는 어떻게 이해하면 되는가? + +백트래킹은 '알고리즘 전략'이고, 재귀는 '도구'에 더 가깝다. + +- 백트래킹은 보통 재귀로 구현한다. 백트래킹은 재귀의 활용 사례 중 하나이며, 탐색 문제에 재귀를 적용한 것이다. +- 재귀의 구조는 '부분 문제로 분해'하는 풀이 패턴을 보여 주며, 분할 정복, 백트래킹, 동적 계획법(메모이제이션을 이용한 재귀) 등의 문제에 자주 사용한다. diff --git a/ko/docs/chapter_computational_complexity/exercises.md b/ko/docs/chapter_computational_complexity/exercises.md new file mode 100644 index 000000000..8037b5ad6 --- /dev/null +++ b/ko/docs/chapter_computational_complexity/exercises.md @@ -0,0 +1,760 @@ +--- +comments: true +--- + +# 2.6   연습 문제 + +## 2.6.1   개념 확인 + +### 1.   반복과 재귀의 시간과 공간 + +다음 두 코드는 모두 $1 + 2 + \dots + n$을 계산한다($n \ge 1$로 가정한다). `n`을 4로 설정하고, +프로그램이 실제로 실행되는 순서에 따라 질문에 답한 뒤 두 방식의 효율성을 비교해 보자. + +=== "Python" + + ```python title="complexity_exercises.py" + def sum_iter(n: int) -> int: + """반복으로 합 구하기""" + res = 0 + for i in range(1, n + 1): + res += i + return res + + def sum_recur(n: int) -> int: + """재귀로 합 구하기""" + if n == 1: + return 1 + return n + sum_recur(n - 1) + ``` + +=== "C++" + + ```cpp title="complexity_exercises.cpp" + /* 반복으로 합 구하기 */ + int sumIter(int n) { + int res = 0; + for (int i = 1; i <= n; ++i) { + res += i; + } + return res; + } + + /* 재귀로 합 구하기 */ + int sumRecur(int n) { + if (n == 1) { + return 1; + } + return n + sumRecur(n - 1); + } + ``` + +=== "Java" + + ```java title="complexity_exercises.java" + /* 반복으로 합 구하기 */ + int sumIter(int n) { + int res = 0; + for (int i = 1; i <= n; i++) { + res += i; + } + return res; + } + + /* 재귀로 합 구하기 */ + int sumRecur(int n) { + if (n == 1) { + return 1; + } + return n + sumRecur(n - 1); + } + ``` + +=== "C#" + + ```csharp title="complexity_exercises.cs" + /* 반복으로 합 구하기 */ + int SumIter(int n) { + int res = 0; + for (int i = 1; i <= n; i++) { + res += i; + } + return res; + } + + /* 재귀로 합 구하기 */ + int SumRecur(int n) { + if (n == 1) { + return 1; + } + return n + SumRecur(n - 1); + } + ``` + +=== "Go" + + ```go title="complexity_exercises.go" + /* 반복으로 합 구하기 */ + func sumIter(n int) int { + res := 0 + for i := 1; i <= n; i++ { + res += i + } + return res + } + + /* 재귀로 합 구하기 */ + func sumRecur(n int) int { + if n == 1 { + return 1 + } + return n + sumRecur(n-1) + } + ``` + +=== "Swift" + + ```swift title="complexity_exercises.swift" + /* 반복으로 합 구하기 */ + func sumIter(n: Int) -> Int { + var res = 0 + for i in 1 ... n { + res += i + } + return res + } + + /* 재귀로 합 구하기 */ + func sumRecur(n: Int) -> Int { + if n == 1 { + return 1 + } + return n + sumRecur(n: n - 1) + } + ``` + +=== "JS" + + ```javascript title="complexity_exercises.js" + /* 반복으로 합 구하기 */ + function sumIter(n) { + let res = 0; + for (let i = 1; i <= n; i++) { + res += i; + } + return res; + } + + /* 재귀로 합 구하기 */ + function sumRecur(n) { + if (n === 1) { + return 1; + } + return n + sumRecur(n - 1); + } + ``` + +=== "TS" + + ```typescript title="complexity_exercises.ts" + /* 반복으로 합 구하기 */ + function sumIter(n: number): number { + let res = 0; + for (let i = 1; i <= n; i++) { + res += i; + } + return res; + } + + /* 재귀로 합 구하기 */ + function sumRecur(n: number): number { + if (n === 1) { + return 1; + } + return n + sumRecur(n - 1); + } + ``` + +=== "Dart" + + ```dart title="complexity_exercises.dart" + /* 반복으로 합 구하기 */ + int sumIter(int n) { + int res = 0; + for (int i = 1; i <= n; i++) { + res += i; + } + return res; + } + + /* 재귀로 합 구하기 */ + int sumRecur(int n) { + if (n == 1) { + return 1; + } + return n + sumRecur(n - 1); + } + ``` + +=== "Rust" + + ```rust title="complexity_exercises.rs" + /* 반복으로 합 구하기 */ + fn sum_iter(n: i32) -> i32 { + let mut res = 0; + for i in 1..=n { + res += i; + } + res + } + + /* 재귀로 합 구하기 */ + fn sum_recur(n: i32) -> i32 { + if n == 1 { + return 1; + } + n + sum_recur(n - 1) + } + ``` + +=== "C" + + ```c title="complexity_exercises.c" + /* 반복으로 합 구하기 */ + int sumIter(int n) { + int res = 0; + for (int i = 1; i <= n; i++) { + res += i; + } + return res; + } + + /* 재귀로 합 구하기 */ + int sumRecur(int n) { + if (n == 1) { + return 1; + } + return n + sumRecur(n - 1); + } + ``` + +=== "Kotlin" + + ```kotlin title="complexity_exercises.kt" + /* 반복으로 합 구하기 */ + fun sumIter(n: Int): Int { + var res = 0 + for (i in 1..n) { + res += i + } + return res + } + + /* 재귀로 합 구하기 */ + fun sumRecur(n: Int): Int { + if (n == 1) { + return 1 + } + return n + sumRecur(n - 1) + } + ``` + +=== "Ruby" + + ```ruby title="complexity_exercises.rb" + ### 반복으로 합 구하기 ### + def sum_iter(n) + res = 0 + for i in 1..n + res += i + end + res + end + + ### 재귀로 합 구하기 ### + def sum_recur(n) + return 1 if n == 1 + + n + sum_recur(n - 1) + end + ``` + + + +1. `n = 4`를 입력하여 반복 함수를 실행할 때, 각 반복이 끝난 뒤 누적 변수 `res`의 값은 각각 얼마인가? +2. `n = 4`를 입력하여 재귀 함수를 실행할 때, 매개변수 `n`은 어떤 값을 순서대로 갖는가? 가장 깊은 호출부터 반환할 때 결과는 어떻게 구해지는가? +3. 두 방식의 시간 복잡도와 공간 복잡도는 각각 얼마인가? 1번과 2번의 실행 과정을 바탕으로 이유를 설명하라. + +??? success "참고 답안" + + 1. 반복 변수 `i`는 차례대로 `1, 2, 3, 4`가 되고, 각 반복이 끝날 때 `res`는 + `1, 3, 6, 10`으로 변한다. 따라서 반복 함수는 10을 반환한다. + + 2. 매개변수 `n`은 `4 → 3 → 2 → 1` 순서로 변한다. + 가장 깊은 호출이 1을 반환한 뒤, 각 호출은 차례대로 `2 + 1 = 3`, `3 + 3 = 6`, `4 + 6 = 10`을 얻는다. + 가장 깊은 지점에서는 4번의 함수 호출이 모두 아직 끝나지 않은 상태다. + + 3. 두 코드 모두 $n$에 비례하는 횟수만큼 반복하거나 호출하므로 시간 복잡도는 모두 $O(n)$이다. + 공간 복잡도는 다르다. 반복 방식은 상수 개의 변수만 사용하므로 $O(1)$이다. + 재귀 방식은 종료 조건에 도달할 때까지 앞선 호출들이 반환값을 기다려야 하므로 호출 스택에 최대 $n$번의 호출이 동시에 저장된다. + 따라서 공간 복잡도는 $O(n)$이다. + + 공간 복잡도를 분석할 때는 코드에 선언된 변수뿐 아니라 재귀 호출이 사용하는 공간도 고려해야 한다. + +### 2.   세 코드의 시간 복잡도 + +다음 세 코드 조각은 모두 양의 정수 $n$을 입력받는다. 시간 복잡도가 낮은 순서대로 나열하고 각각의 복잡도를 적어 보자. + +=== "Python" + + ```python title="complexity_exercises.py" + def linear_loop(n: int) -> int: + """선형 차수 반복문""" + res = 0 + for i in range(n): + res += i + return res + + def quadratic_loop(n: int) -> int: + """제곱 차수 반복문""" + res = 0 + for i in range(n): + for j in range(i, n): + res += j + return res + + def logarithmic_loop(n: int) -> int: + """로그 차수 반복문""" + while n > 1: + n //= 2 + return n + ``` + +=== "C++" + + ```cpp title="complexity_exercises.cpp" + /* 선형 차수 반복문 */ + int linearLoop(int n) { + int res = 0; + for (int i = 0; i < n; ++i) { + res += i; + } + return res; + } + + /* 제곱 차수 반복문 */ + int quadraticLoop(int n) { + int res = 0; + for (int i = 0; i < n; ++i) { + for (int j = i; j < n; ++j) { + res += j; + } + } + return res; + } + + /* 로그 차수 반복문 */ + int logarithmicLoop(int n) { + while (n > 1) { + n /= 2; + } + return n; + } + ``` + +=== "Java" + + ```java title="complexity_exercises.java" + /* 선형 차수 반복문 */ + int linearLoop(int n) { + int res = 0; + for (int i = 0; i < n; i++) { + res += i; + } + return res; + } + + /* 제곱 차수 반복문 */ + int quadraticLoop(int n) { + int res = 0; + for (int i = 0; i < n; i++) { + for (int j = i; j < n; j++) { + res += j; + } + } + return res; + } + + /* 로그 차수 반복문 */ + int logarithmicLoop(int n) { + while (n > 1) { + n /= 2; + } + return n; + } + ``` + +=== "C#" + + ```csharp title="complexity_exercises.cs" + /* 선형 차수 반복문 */ + int LinearLoop(int n) { + int res = 0; + for (int i = 0; i < n; i++) { + res += i; + } + return res; + } + + /* 제곱 차수 반복문 */ + int QuadraticLoop(int n) { + int res = 0; + for (int i = 0; i < n; i++) { + for (int j = i; j < n; j++) { + res += j; + } + } + return res; + } + + /* 로그 차수 반복문 */ + int LogarithmicLoop(int n) { + while (n > 1) { + n /= 2; + } + return n; + } + ``` + +=== "Go" + + ```go title="complexity_exercises.go" + /* 선형 차수 반복문 */ + func linearLoop(n int) int { + res := 0 + for i := 0; i < n; i++ { + res += i + } + return res + } + + /* 제곱 차수 반복문 */ + func quadraticLoop(n int) int { + res := 0 + for i := 0; i < n; i++ { + for j := i; j < n; j++ { + res += j + } + } + return res + } + + /* 로그 차수 반복문 */ + func logarithmicLoop(n int) int { + for n > 1 { + n /= 2 + } + return n + } + ``` + +=== "Swift" + + ```swift title="complexity_exercises.swift" + /* 선형 차수 반복문 */ + func linearLoop(n: Int) -> Int { + var res = 0 + for i in 0 ..< n { + res += i + } + return res + } + + /* 제곱 차수 반복문 */ + func quadraticLoop(n: Int) -> Int { + var res = 0 + for i in 0 ..< n { + for j in i ..< n { + res += j + } + } + return res + } + + /* 로그 차수 반복문 */ + func logarithmicLoop(n: Int) -> Int { + var n = n + while n > 1 { + n /= 2 + } + return n + } + ``` + +=== "JS" + + ```javascript title="complexity_exercises.js" + /* 선형 차수 반복문 */ + function linearLoop(n) { + let res = 0; + for (let i = 0; i < n; i++) { + res += i; + } + return res; + } + + /* 제곱 차수 반복문 */ + function quadraticLoop(n) { + let res = 0; + for (let i = 0; i < n; i++) { + for (let j = i; j < n; j++) { + res += j; + } + } + return res; + } + + /* 로그 차수 반복문 */ + function logarithmicLoop(n) { + while (n > 1) { + n = Math.floor(n / 2); + } + return n; + } + ``` + +=== "TS" + + ```typescript title="complexity_exercises.ts" + /* 선형 차수 반복문 */ + function linearLoop(n: number): number { + let res = 0; + for (let i = 0; i < n; i++) { + res += i; + } + return res; + } + + /* 제곱 차수 반복문 */ + function quadraticLoop(n: number): number { + let res = 0; + for (let i = 0; i < n; i++) { + for (let j = i; j < n; j++) { + res += j; + } + } + return res; + } + + /* 로그 차수 반복문 */ + function logarithmicLoop(n: number): number { + while (n > 1) { + n = Math.floor(n / 2); + } + return n; + } + ``` + +=== "Dart" + + ```dart title="complexity_exercises.dart" + /* 선형 차수 반복문 */ + int linearLoop(int n) { + int res = 0; + for (int i = 0; i < n; i++) { + res += i; + } + return res; + } + + /* 제곱 차수 반복문 */ + int quadraticLoop(int n) { + int res = 0; + for (int i = 0; i < n; i++) { + for (int j = i; j < n; j++) { + res += j; + } + } + return res; + } + + /* 로그 차수 반복문 */ + int logarithmicLoop(int n) { + while (n > 1) { + n ~/= 2; + } + return n; + } + ``` + +=== "Rust" + + ```rust title="complexity_exercises.rs" + /* 선형 차수 반복문 */ + fn linear_loop(n: i32) -> i32 { + let mut res = 0; + for i in 0..n { + res += i; + } + res + } + + /* 제곱 차수 반복문 */ + fn quadratic_loop(n: i32) -> i32 { + let mut res = 0; + for i in 0..n { + for j in i..n { + res += j; + } + } + res + } + + /* 로그 차수 반복문 */ + fn logarithmic_loop(mut n: i32) -> i32 { + while n > 1 { + n /= 2; + } + n + } + ``` + +=== "C" + + ```c title="complexity_exercises.c" + /* 선형 차수 반복문 */ + int linearLoop(int n) { + int res = 0; + for (int i = 0; i < n; i++) { + res += i; + } + return res; + } + + /* 제곱 차수 반복문 */ + int quadraticLoop(int n) { + int res = 0; + for (int i = 0; i < n; i++) { + for (int j = i; j < n; j++) { + res += j; + } + } + return res; + } + + /* 로그 차수 반복문 */ + int logarithmicLoop(int n) { + while (n > 1) { + n /= 2; + } + return n; + } + ``` + +=== "Kotlin" + + ```kotlin title="complexity_exercises.kt" + /* 선형 차수 반복문 */ + fun linearLoop(n: Int): Int { + var res = 0 + for (i in 0 until n) { + res += i + } + return res + } + + /* 제곱 차수 반복문 */ + fun quadraticLoop(n: Int): Int { + var res = 0 + for (i in 0 until n) { + for (j in i until n) { + res += j + } + } + return res + } + + /* 로그 차수 반복문 */ + fun logarithmicLoop(n: Int): Int { + var value = n + while (value > 1) { + value /= 2 + } + return value + } + ``` + +=== "Ruby" + + ```ruby title="complexity_exercises.rb" + ### 선형 차수 반복문 ### + def linear_loop(n) + res = 0 + for i in 0...n + res += i + end + res + end + + ### 제곱 차수 반복문 ### + def quadratic_loop(n) + res = 0 + for i in 0...n + for j in i...n + res += j + end + end + res + end + + ### 로그 차수 반복문 ### + def logarithmic_loop(n) + n /= 2 while n > 1 + n + end + ``` + +??? success "참고 답안" + + 낮은 순서대로 코드 3은 $O(\log n)$, 코드 1은 $O(n)$, 코드 2는 $O(n^2)$이다. + 코드 3은 매번 $n$을 절반으로 줄이므로 반복 횟수는 약 $\log_2 n$번이다. + 코드 1의 반복문은 정확히 $n$번 실행된다. 코드 2의 안쪽 반복 횟수는 차례대로 + $n,n-1,\dots,1$이므로 총 $n(n+1)/2$번이며, 제곱 차수에 해당한다. + +### 3.   어느 뒤집기 방식이 공간을 더 절약하는가 + +배열 `nums`의 모든 원소를 역순으로 뒤집는 두 가지 방법이 있다. + + + +1. 길이가 같은 새 배열 `res`를 만들고 원소를 역순으로 복사한 뒤 반환한다. +2. 두 인덱스 `i`와 `j`를 각각 처음과 끝에서 가운데로 이동시키며 `nums[i]`와 `nums[j]`를 쌍으로 교환한다. + + 두 방법의 공간 복잡도는 각각 얼마인가? 어느 방법이 ‘제자리’ 연산에 해당하는가? + +??? success "참고 답안" + + 1. 입력과 길이가 같은 보조 배열이 필요하므로 공간 복잡도는 $O(n)$이다. + + 2. 인덱스 변수 두 개만 사용하므로 + 공간 복잡도는 $O(1)$이며, 제자리 연산이다. + + 주의할 점은 제자리 뒤집기가 입력 배열을 변경한다는 것이다. + 입력을 변경해도 되는 경우에만 우선적으로 선택해야 한다. 원본 배열을 보존해야 한다면 첫 번째 방법의 복사 비용은 피할 수 없다. + +## 2.6.2   프로그래밍 연습 + +### 1.   피보나치 수 + +피보나치 수열은 $F(0)=0$, $F(1)=1$이며, $n\ge2$일 때 +$F(n)=F(n-1)+F(n-2)$를 만족한다. + +음이 아닌 정수 `n`이 주어질 때, 재귀를 사용하지 않고 반복문으로 $F(n)$을 계산하여 반환하라. + +??? tip "풀이 힌트" + + 1. n이 0 또는 1인 경우를 먼저 따로 처리한다. + 2. 다음 항을 계산할 때는 앞선 두 항만 필요하므로 수열 전체를 저장할 필요가 없다. + 3. 두 변수를 갱신할 때 아직 사용해야 하는 이전 값을 너무 일찍 덮어쓰지 않도록 주의한다. + +[LeetCode](https://leetcode.com/problems/fibonacci-number/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_computational_complexity/index.md b/ko/docs/chapter_computational_complexity/index.md new file mode 100644 index 000000000..86b95ff8e --- /dev/null +++ b/ko/docs/chapter_computational_complexity/index.md @@ -0,0 +1,23 @@ +--- +comments: true +icon: material/timer-sand +--- + +# 제 2 장   복잡도 분석 + +![복잡도 분석](../assets/covers/chapter_complexity_analysis.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 복잡도 분석은 광활한 알고리즘 우주에서 시간과 공간을 안내하는 길잡이와 같다. + + 시간과 공간이라는 두 차원을 깊이 탐구하며 더 우아한 해법을 찾도록 이끌어 준다. + +## 이 장의 내용 + +- [2.1   알고리즘 효율 평가](performance_evaluation.md) +- [2.2   반복과 재귀](iteration_and_recursion.md) +- [2.3   시간 복잡도](time_complexity.md) +- [2.4   공간 복잡도](space_complexity.md) +- [2.5   요약](summary.md) +- [2.6   연습 문제](exercises.md) diff --git a/ko/docs/chapter_computational_complexity/iteration_and_recursion.md b/ko/docs/chapter_computational_complexity/iteration_and_recursion.md new file mode 100644 index 000000000..bd89db4f9 --- /dev/null +++ b/ko/docs/chapter_computational_complexity/iteration_and_recursion.md @@ -0,0 +1,1928 @@ +--- +comments: true +--- + +# 2.2   반복과 재귀 + +알고리즘에서는 특정 작업을 반복해서 실행하는 일이 흔하며, 이는 복잡도 분석과 밀접하게 연결되어 있다. 따라서 시간 복잡도와 공간 복잡도를 소개하기 전에, 프로그램에서 작업을 반복 실행하는 두 가지 기본 제어 구조인 반복과 재귀부터 알아보자. + +## 2.2.1   반복 + +반복(iteration)은 특정 작업을 거듭 실행하는 제어 구조다. 반복에서는 일정한 조건을 만족하는 동안 코드 일부를 계속 실행하다가, 조건을 더 이상 만족하지 않으면 멈춘다. + +### 1.   for 반복문 + +`for` 반복문은 가장 흔한 반복 형식 중 하나로, **반복 횟수를 미리 알고 있을 때 적합하다**. + +다음 함수는 `for` 반복문으로 $1 + 2 + \dots + n$을 계산하고, 그 합을 변수 `res`에 기록한다. Python의 `range(a, b)`는 왼쪽 끝을 포함하고 오른쪽 끝은 포함하지 않는 구간이므로, 순회 범위는 $a, a + 1, \dots, b-1$이다. + +=== "Python" + + ```python title="iteration.py" + def for_loop(n: int) -> int: + """for 반복문""" + res = 0 + # 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for i in range(1, n + 1): + res += i + return res + ``` + +=== "C++" + + ```cpp title="iteration.cpp" + /* for 반복문 */ + int forLoop(int n) { + int res = 0; + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for (int i = 1; i <= n; ++i) { + res += i; + } + return res; + } + ``` + +=== "Java" + + ```java title="iteration.java" + /* for 반복문 */ + int forLoop(int n) { + int res = 0; + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for (int i = 1; i <= n; i++) { + res += i; + } + return res; + } + ``` + +=== "C#" + + ```csharp title="iteration.cs" + /* for 반복문 */ + int ForLoop(int n) { + int res = 0; + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for (int i = 1; i <= n; i++) { + res += i; + } + return res; + } + ``` + +=== "Go" + + ```go title="iteration.go" + /* for 반복문 */ + func forLoop(n int) int { + res := 0 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for i := 1; i <= n; i++ { + res += i + } + return res + } + ``` + +=== "Swift" + + ```swift title="iteration.swift" + /* for 반복문 */ + func forLoop(n: Int) -> Int { + var res = 0 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for i in 1 ... n { + res += i + } + return res + } + ``` + +=== "JS" + + ```javascript title="iteration.js" + /* for 반복문 */ + function forLoop(n) { + let res = 0; + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for (let i = 1; i <= n; i++) { + res += i; + } + return res; + } + ``` + +=== "TS" + + ```typescript title="iteration.ts" + /* for 반복문 */ + function forLoop(n: number): number { + let res = 0; + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for (let i = 1; i <= n; i++) { + res += i; + } + return res; + } + ``` + +=== "Dart" + + ```dart title="iteration.dart" + /* for 반복문 */ + int forLoop(int n) { + int res = 0; + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for (int i = 1; i <= n; i++) { + res += i; + } + return res; + } + ``` + +=== "Rust" + + ```rust title="iteration.rs" + /* for 반복문 */ + fn for_loop(n: i32) -> i32 { + let mut res = 0; + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for i in 1..=n { + res += i; + } + res + } + ``` + +=== "C" + + ```c title="iteration.c" + /* for 반복문 */ + int forLoop(int n) { + int res = 0; + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for (int i = 1; i <= n; i++) { + res += i; + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="iteration.kt" + /* for 반복문 */ + fun forLoop(n: Int): Int { + var res = 0 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for (i in 1..n) { + res += i + } + return res + } + ``` + +=== "Ruby" + + ```ruby title="iteration.rb" + ### for 반복문 ### + def for_loop(n) + res = 0 + + # 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for i in 1..n + res += i + end + + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(2-1)은 이 합계 함수의 순서도다. + +![합계 함수의 순서도](iteration_and_recursion.assets/iteration.png){ class="animation-figure" } + +

그림 2-1   합계 함수의 순서도

+ +이 합계 함수의 연산 횟수는 입력 데이터 크기 $n$에 비례한다. 이를 ‘선형 관계’라고도 한다. 실제로 **시간 복잡도가 설명하는 것이 바로 이 ‘선형 관계’다**. 관련 내용은 다음 절에서 자세히 소개한다. + +### 2.   while 반복문 + +`for` 반복문과 마찬가지로 `while` 반복문도 반복을 구현하는 방법이다. `while` 반복문은 매번 먼저 조건을 검사하여 참이면 실행을 계속하고, 그렇지 않으면 반복을 끝낸다. + +다음은 `while` 반복문으로 $1 + 2 + \dots + n$을 계산하는 코드다. + +=== "Python" + + ```python title="iteration.py" + def while_loop(n: int) -> int: + """while 반복문""" + res = 0 + i = 1 # 조건 변수를 초기화한다 + # 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while i <= n: + res += i + i += 1 # 조건 변수를 갱신한다 + return res + ``` + +=== "C++" + + ```cpp title="iteration.cpp" + /* while 반복문 */ + int whileLoop(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while (i <= n) { + res += i; + i++; // 조건 변수를 갱신한다 + } + return res; + } + ``` + +=== "Java" + + ```java title="iteration.java" + /* while 반복문 */ + int whileLoop(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while (i <= n) { + res += i; + i++; // 조건 변수를 갱신한다 + } + return res; + } + ``` + +=== "C#" + + ```csharp title="iteration.cs" + /* while 반복문 */ + int WhileLoop(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while (i <= n) { + res += i; + i += 1; // 조건 변수를 갱신한다 + } + return res; + } + ``` + +=== "Go" + + ```go title="iteration.go" + /* while 반복문 */ + func whileLoop(n int) int { + res := 0 + // 조건 변수를 초기화한다 + i := 1 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + for i <= n { + res += i + // 조건 변수를 갱신한다 + i++ + } + return res + } + ``` + +=== "Swift" + + ```swift title="iteration.swift" + /* while 반복문 */ + func whileLoop(n: Int) -> Int { + var res = 0 + var i = 1 // 조건 변수를 초기화한다 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while i <= n { + res += i + i += 1 // 조건 변수를 갱신한다 + } + return res + } + ``` + +=== "JS" + + ```javascript title="iteration.js" + /* while 반복문 */ + function whileLoop(n) { + let res = 0; + let i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while (i <= n) { + res += i; + i++; // 조건 변수를 갱신한다 + } + return res; + } + ``` + +=== "TS" + + ```typescript title="iteration.ts" + /* while 반복문 */ + function whileLoop(n: number): number { + let res = 0; + let i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while (i <= n) { + res += i; + i++; // 조건 변수를 갱신한다 + } + return res; + } + ``` + +=== "Dart" + + ```dart title="iteration.dart" + /* while 반복문 */ + int whileLoop(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while (i <= n) { + res += i; + i++; // 조건 변수를 갱신한다 + } + return res; + } + ``` + +=== "Rust" + + ```rust title="iteration.rs" + /* while 반복문 */ + fn while_loop(n: i32) -> i32 { + let mut res = 0; + let mut i = 1; // 조건 변수를 초기화한다 + + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while i <= n { + res += i; + i += 1; // 조건 변수를 갱신한다 + } + res + } + ``` + +=== "C" + + ```c title="iteration.c" + /* while 반복문 */ + int whileLoop(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while (i <= n) { + res += i; + i++; // 조건 변수를 갱신한다 + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="iteration.kt" + /* while 반복문 */ + fun whileLoop(n: Int): Int { + var res = 0 + var i = 1 // 조건 변수를 초기화한다 + // 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while (i <= n) { + res += i + i++ // 조건 변수를 갱신한다 + } + return res + } + ``` + +=== "Ruby" + + ```ruby title="iteration.rb" + ### while 반복문 ### + def while_loop(n) + res = 0 + i = 1 # 조건 변수를 초기화한다 + + # 반복문으로 1, 2, ..., n-1, n의 합을 구한다 + while i <= n + res += i + i += 1 # 조건 변수를 갱신한다 + end + + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +**`while` 반복문은 `for` 반복문보다 자유도가 높다**. `while` 반복문에서는 조건 변수의 초기화와 갱신 단계를 자유롭게 설계할 수 있다. + +예를 들어 다음 코드에서는 조건 변수 $i$를 매번 두 차례 갱신한다. 이런 경우에는 `for` 반복문으로 구현하기가 다소 불편하다. + +=== "Python" + + ```python title="iteration.py" + def while_loop_ii(n: int) -> int: + """while 반복문(두 번 갱신)""" + res = 0 + i = 1 # 조건 변수를 초기화한다 + # 반복문으로 1, 4, 10, ...의 합을 구한다 + while i <= n: + res += i + # 조건 변수를 갱신한다 + i += 1 + i *= 2 + return res + ``` + +=== "C++" + + ```cpp title="iteration.cpp" + /* while 반복문(두 번 갱신) */ + int whileLoopII(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while (i <= n) { + res += i; + // 조건 변수를 갱신한다 + i++; + i *= 2; + } + return res; + } + ``` + +=== "Java" + + ```java title="iteration.java" + /* while 반복문(두 번 갱신) */ + int whileLoopII(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while (i <= n) { + res += i; + // 조건 변수를 갱신한다 + i++; + i *= 2; + } + return res; + } + ``` + +=== "C#" + + ```csharp title="iteration.cs" + /* while 반복문(두 번 갱신) */ + int WhileLoopII(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while (i <= n) { + res += i; + // 조건 변수를 갱신한다 + i += 1; + i *= 2; + } + return res; + } + ``` + +=== "Go" + + ```go title="iteration.go" + /* while 반복문(두 번 갱신) */ + func whileLoopII(n int) int { + res := 0 + // 조건 변수를 초기화한다 + i := 1 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + for i <= n { + res += i + // 조건 변수를 갱신한다 + i++ + i *= 2 + } + return res + } + ``` + +=== "Swift" + + ```swift title="iteration.swift" + /* while 반복문(두 번 갱신) */ + func whileLoopII(n: Int) -> Int { + var res = 0 + var i = 1 // 조건 변수를 초기화한다 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while i <= n { + res += i + // 조건 변수를 갱신한다 + i += 1 + i *= 2 + } + return res + } + ``` + +=== "JS" + + ```javascript title="iteration.js" + /* while 반복문(두 번 갱신) */ + function whileLoopII(n) { + let res = 0; + let i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while (i <= n) { + res += i; + // 조건 변수를 갱신한다 + i++; + i *= 2; + } + return res; + } + ``` + +=== "TS" + + ```typescript title="iteration.ts" + /* while 반복문(두 번 갱신) */ + function whileLoopII(n: number): number { + let res = 0; + let i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while (i <= n) { + res += i; + // 조건 변수를 갱신한다 + i++; + i *= 2; + } + return res; + } + ``` + +=== "Dart" + + ```dart title="iteration.dart" + /* while 반복문(두 번 갱신) */ + int whileLoopII(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while (i <= n) { + res += i; + // 조건 변수를 갱신한다 + i++; + i *= 2; + } + return res; + } + ``` + +=== "Rust" + + ```rust title="iteration.rs" + /* while 반복문(두 번 갱신) */ + fn while_loop_ii(n: i32) -> i32 { + let mut res = 0; + let mut i = 1; // 조건 변수를 초기화한다 + + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while i <= n { + res += i; + // 조건 변수를 갱신한다 + i += 1; + i *= 2; + } + res + } + ``` + +=== "C" + + ```c title="iteration.c" + /* while 반복문(두 번 갱신) */ + int whileLoopII(int n) { + int res = 0; + int i = 1; // 조건 변수를 초기화한다 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while (i <= n) { + res += i; + // 조건 변수를 갱신한다 + i++; + i *= 2; + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="iteration.kt" + /* while 반복문(두 번 갱신) */ + fun whileLoopII(n: Int): Int { + var res = 0 + var i = 1 // 조건 변수를 초기화한다 + // 반복문으로 1, 4, 10, ...의 합을 구한다 + while (i <= n) { + res += i + // 조건 변수를 갱신한다 + i++ + i *= 2 + } + return res + } + ``` + +=== "Ruby" + + ```ruby title="iteration.rb" + ### while 반복문(두 번 갱신)### + def while_loop_ii(n) + res = 0 + i = 1 # 조건 변수를 초기화한다 + + # 반복문으로 1, 4, 10, ...의 합을 구한다 + while i <= n + res += i + # 조건 변수를 갱신한다 + i += 1 + i *= 2 + end + + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +전반적으로 **`for` 반복문은 코드가 더 간결하고, `while` 반복문은 더 유연하다**. 둘 다 반복 구조를 구현할 수 있으며, 어떤 것을 쓸지는 문제의 구체적인 요구에 따라 결정해야 한다. + +### 3.   중첩 반복문 + +하나의 반복 구조 안에 다른 반복 구조를 중첩할 수 있다. 다음은 `for` 반복문을 사용한 예다. + +=== "Python" + + ```python title="iteration.py" + def nested_for_loop(n: int) -> str: + """이중 for 반복문""" + res = "" + # i = 1, 2, ..., n-1, n을 반복한다 + for i in range(1, n + 1): + # j = 1, 2, ..., n-1, n을 반복한다 + for j in range(1, n + 1): + res += f"({i}, {j}), " + return res + ``` + +=== "C++" + + ```cpp title="iteration.cpp" + /* 이중 for 반복문 */ + string nestedForLoop(int n) { + ostringstream res; + // i = 1, 2, ..., n-1, n을 반복한다 + for (int i = 1; i <= n; ++i) { + // j = 1, 2, ..., n-1, n을 반복한다 + for (int j = 1; j <= n; ++j) { + res << "(" << i << ", " << j << "), "; + } + } + return res.str(); + } + ``` + +=== "Java" + + ```java title="iteration.java" + /* 이중 for 반복문 */ + String nestedForLoop(int n) { + StringBuilder res = new StringBuilder(); + // i = 1, 2, ..., n-1, n을 반복한다 + for (int i = 1; i <= n; i++) { + // j = 1, 2, ..., n-1, n을 반복한다 + for (int j = 1; j <= n; j++) { + res.append("(" + i + ", " + j + "), "); + } + } + return res.toString(); + } + ``` + +=== "C#" + + ```csharp title="iteration.cs" + /* 이중 for 반복문 */ + string NestedForLoop(int n) { + StringBuilder res = new(); + // i = 1, 2, ..., n-1, n을 반복한다 + for (int i = 1; i <= n; i++) { + // j = 1, 2, ..., n-1, n을 반복한다 + for (int j = 1; j <= n; j++) { + res.Append($"({i}, {j}), "); + } + } + return res.ToString(); + } + ``` + +=== "Go" + + ```go title="iteration.go" + /* 이중 for 반복문 */ + func nestedForLoop(n int) string { + res := "" + // i = 1, 2, ..., n-1, n을 반복한다 + for i := 1; i <= n; i++ { + for j := 1; j <= n; j++ { + // j = 1, 2, ..., n-1, n을 반복한다 + res += fmt.Sprintf("(%d, %d), ", i, j) + } + } + return res + } + ``` + +=== "Swift" + + ```swift title="iteration.swift" + /* 이중 for 반복문 */ + func nestedForLoop(n: Int) -> String { + var res = "" + // i = 1, 2, ..., n-1, n을 반복한다 + for i in 1 ... n { + // j = 1, 2, ..., n-1, n을 반복한다 + for j in 1 ... n { + res.append("(\(i), \(j)), ") + } + } + return res + } + ``` + +=== "JS" + + ```javascript title="iteration.js" + /* 이중 for 반복문 */ + function nestedForLoop(n) { + let res = ''; + // i = 1, 2, ..., n-1, n을 반복한다 + for (let i = 1; i <= n; i++) { + // j = 1, 2, ..., n-1, n을 반복한다 + for (let j = 1; j <= n; j++) { + res += `(${i}, ${j}), `; + } + } + return res; + } + ``` + +=== "TS" + + ```typescript title="iteration.ts" + /* 이중 for 반복문 */ + function nestedForLoop(n: number): string { + let res = ''; + // i = 1, 2, ..., n-1, n을 반복한다 + for (let i = 1; i <= n; i++) { + // j = 1, 2, ..., n-1, n을 반복한다 + for (let j = 1; j <= n; j++) { + res += `(${i}, ${j}), `; + } + } + return res; + } + ``` + +=== "Dart" + + ```dart title="iteration.dart" + /* 이중 for 반복문 */ + String nestedForLoop(int n) { + String res = ""; + // i = 1, 2, ..., n-1, n을 반복한다 + for (int i = 1; i <= n; i++) { + // j = 1, 2, ..., n-1, n을 반복한다 + for (int j = 1; j <= n; j++) { + res += "($i, $j), "; + } + } + return res; + } + ``` + +=== "Rust" + + ```rust title="iteration.rs" + /* 이중 for 반복문 */ + fn nested_for_loop(n: i32) -> String { + let mut res = vec![]; + // i = 1, 2, ..., n-1, n을 반복한다 + for i in 1..=n { + // j = 1, 2, ..., n-1, n을 반복한다 + for j in 1..=n { + res.push(format!("({}, {}), ", i, j)); + } + } + res.join("") + } + ``` + +=== "C" + + ```c title="iteration.c" + /* 이중 for 반복문 */ + char *nestedForLoop(int n) { + // n * n은 해당 점의 개수이며, "(i, j), "의 최대 문자열 길이는 6+10*2이다. 마지막 널 문자 \0을 위한 공간도 추가한다 + int size = n * n * 26 + 1; + char *res = malloc(size * sizeof(char)); + // i = 1, 2, ..., n-1, n을 반복한다 + for (int i = 1; i <= n; i++) { + // j = 1, 2, ..., n-1, n을 반복한다 + for (int j = 1; j <= n; j++) { + char tmp[26]; + snprintf(tmp, sizeof(tmp), "(%d, %d), ", i, j); + strncat(res, tmp, size - strlen(res) - 1); + } + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="iteration.kt" + /* 이중 for 반복문 */ + fun nestedForLoop(n: Int): String { + val res = StringBuilder() + // i = 1, 2, ..., n-1, n을 반복한다 + for (i in 1..n) { + // j = 1, 2, ..., n-1, n을 반복한다 + for (j in 1..n) { + res.append(" ($i, $j), ") + } + } + return res.toString() + } + ``` + +=== "Ruby" + + ```ruby title="iteration.rb" + ### 이중 for 반복문 ### + def nested_for_loop(n) + res = "" + + # i = 1, 2, ..., n-1, n을 반복한다 + for i in 1..n + # j = 1, 2, ..., n-1, n을 반복한다 + for j in 1..n + res += "(#{i}, #{j}), " + end + end + + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(2-2)은 이 중첩 반복문의 순서도다. + +![중첩 반복문의 순서도](iteration_and_recursion.assets/nested_iteration.png){ class="animation-figure" } + +

그림 2-2   중첩 반복문의 순서도

+ +이 경우 함수의 연산 횟수는 $n^2$에 비례한다. 즉, 알고리즘 실행 시간과 입력 데이터 크기 $n$ 사이에는 ‘제곱 관계’가 있다. + +반복문을 계속 중첩할 수도 있다. 한 번 중첩할 때마다 ‘차원이 하나씩 늘어나’ 시간 복잡도가 ‘세제곱 관계’, ‘네제곱 관계’ 등으로 높아진다. + +## 2.2.2   재귀 + + 재귀(recursion)는 함수가 자기 자신을 호출하여 문제를 해결하는 알고리즘 전략이다. 크게 두 단계로 이루어진다. + +1. **내려가기**: 프로그램이 자기 자신을 계속 더 깊이 호출한다. 보통 더 작거나 단순한 인수를 전달하며, ‘종료 조건’에 도달할 때까지 이어진다. +2. **돌아오기**: ‘종료 조건’에 도달하면 가장 깊은 재귀 함수부터 한 단계씩 반환하며, 각 단계의 결과를 모은다. + +구현 관점에서 보면 재귀 코드는 크게 세 요소로 이루어진다. + +1. **종료 조건**: ‘내려가기’에서 ‘돌아오기’로 전환할 시점을 결정한다. +2. **재귀 호출**: ‘내려가기’에 해당한다. 함수가 자기 자신을 호출하며, 보통 더 작거나 단순한 인수를 입력한다. +3. **결과 반환**: ‘돌아오기’에 해당한다. 현재 재귀 단계의 결과를 이전 단계로 반환한다. + +다음 코드를 보면, 함수 `recur(n)`을 호출하기만 하면 $1 + 2 + \dots + n$을 계산할 수 있다. + +=== "Python" + + ```python title="recursion.py" + def recur(n: int) -> int: + """재귀""" + # 종료 조건 + if n == 1: + return 1 + # 내려가기: 재귀 호출 + res = recur(n - 1) + # 돌아오기: 결과 반환 + return n + res + ``` + +=== "C++" + + ```cpp title="recursion.cpp" + /* 재귀 */ + int recur(int n) { + // 종료 조건 + if (n == 1) + return 1; + // 내려가기: 재귀 호출 + int res = recur(n - 1); + // 돌아오기: 결과 반환 + return n + res; + } + ``` + +=== "Java" + + ```java title="recursion.java" + /* 재귀 */ + int recur(int n) { + // 종료 조건 + if (n == 1) + return 1; + // 내려가기: 재귀 호출 + int res = recur(n - 1); + // 돌아오기: 결과 반환 + return n + res; + } + ``` + +=== "C#" + + ```csharp title="recursion.cs" + /* 재귀 */ + int Recur(int n) { + // 종료 조건 + if (n == 1) + return 1; + // 내려가기: 재귀 호출 + int res = Recur(n - 1); + // 돌아오기: 결과 반환 + return n + res; + } + ``` + +=== "Go" + + ```go title="recursion.go" + /* 재귀 */ + func recur(n int) int { + // 종료 조건 + if n == 1 { + return 1 + } + // 내려가기: 재귀 호출 + res := recur(n - 1) + // 돌아오기: 결과 반환 + return n + res + } + ``` + +=== "Swift" + + ```swift title="recursion.swift" + /* 재귀 */ + func recur(n: Int) -> Int { + // 종료 조건 + if n == 1 { + return 1 + } + // 내려가기: 재귀 호출 + let res = recur(n: n - 1) + // 돌아오기: 결과 반환 + return n + res + } + ``` + +=== "JS" + + ```javascript title="recursion.js" + /* 재귀 */ + function recur(n) { + // 종료 조건 + if (n === 1) return 1; + // 내려가기: 재귀 호출 + const res = recur(n - 1); + // 돌아오기: 결과 반환 + return n + res; + } + ``` + +=== "TS" + + ```typescript title="recursion.ts" + /* 재귀 */ + function recur(n: number): number { + // 종료 조건 + if (n === 1) return 1; + // 내려가기: 재귀 호출 + const res = recur(n - 1); + // 돌아오기: 결과 반환 + return n + res; + } + ``` + +=== "Dart" + + ```dart title="recursion.dart" + /* 재귀 */ + int recur(int n) { + // 종료 조건 + if (n == 1) return 1; + // 내려가기: 재귀 호출 + int res = recur(n - 1); + // 돌아오기: 결과 반환 + return n + res; + } + ``` + +=== "Rust" + + ```rust title="recursion.rs" + /* 재귀 */ + fn recur(n: i32) -> i32 { + // 종료 조건 + if n == 1 { + return 1; + } + // 내려가기: 재귀 호출 + let res = recur(n - 1); + // 돌아오기: 결과 반환 + n + res + } + ``` + +=== "C" + + ```c title="recursion.c" + /* 재귀 */ + int recur(int n) { + // 종료 조건 + if (n == 1) + return 1; + // 내려가기: 재귀 호출 + int res = recur(n - 1); + // 돌아오기: 결과 반환 + return n + res; + } + ``` + +=== "Kotlin" + + ```kotlin title="recursion.kt" + /* 재귀 */ + fun recur(n: Int): Int { + // 종료 조건 + if (n == 1) + return 1 + // 내려가기: 재귀 호출 + val res = recur(n - 1) + // 돌아오기: 결과 반환 + return n + res + } + ``` + +=== "Ruby" + + ```ruby title="recursion.rb" + ### 재귀 ### + def recur(n) + # 종료 조건 + return 1 if n == 1 + # 내려가기: 재귀 호출 + res = recur(n - 1) + # 돌아오기: 결과 반환 + n + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(2-3)은 이 함수의 재귀 과정을 보여 준다. + +![합계 함수의 재귀 과정](iteration_and_recursion.assets/recursion_sum.png){ class="animation-figure" } + +

그림 2-3   합계 함수의 재귀 과정

+ +반복과 재귀는 계산 결과가 같을 수 있지만, **문제를 생각하고 해결하는 방식은 완전히 다르다**. + +- **반복**: ‘상향식’으로 문제를 해결한다. 가장 기본적인 단계에서 시작해 그 단계를 계속 반복하거나 누적하여 작업을 완료한다. +- **재귀**: ‘하향식’으로 문제를 해결한다. 원래 문제를 같은 형태의 더 작은 부분 문제로 나눈다. 그런 다음 부분 문제를 더 작은 부분 문제로 계속 나누다가 기저 사례에 도달하면 멈춘다(기저 사례의 해는 이미 알고 있다). + +앞의 합계 함수를 예로 들어, 문제를 $f(n) = 1 + 2 + \dots + n$이라고 하자. + +- **반복**: 반복문에서 합을 구하는 과정을 재현한다. $1$부터 $n$까지 순회하면서 매번 더하면 $f(n)$을 구할 수 있다. +- **재귀**: 문제를 부분 문제 $f(n) = n + f(n-1)$로 나누고, 계속 재귀적으로 나누다가 기저 사례 $f(1) = 1$에 도달하면 멈춘다. + +### 1.   호출 스택 + +재귀 함수가 자기 자신을 호출할 때마다 시스템은 새로 실행되는 함수를 위해 메모리를 할당하고 지역 변수, 호출 주소 등의 정보를 저장한다. 이로 인해 두 가지 결과가 나타난다. + +- 함수의 문맥 데이터는 ‘스택 프레임 공간’이라는 메모리 영역에 저장되며, 함수가 반환된 뒤에야 해제된다. 따라서 **재귀는 보통 반복보다 더 많은 메모리 공간을 사용한다**. +- 재귀 함수 호출에는 추가 비용이 발생한다. **따라서 재귀는 보통 반복문보다 시간 효율이 낮다**. + +아래 그림(2-4)과 같이 종료 조건에 도달하기 전에는 아직 반환되지 않은 재귀 함수가 $n$개 동시에 존재하므로, **재귀 깊이는 $n$이다**. + +![재귀 호출 깊이](iteration_and_recursion.assets/recursion_sum_depth.png){ class="animation-figure" } + +

그림 2-4   재귀 호출 깊이

+ +실제로 프로그래밍 언어가 허용하는 재귀 깊이는 보통 제한되어 있으며, 재귀가 너무 깊어지면 스택 오버플로 오류가 발생할 수 있다. + +### 2.   꼬리 재귀 + +흥미롭게도 **함수가 반환하기 직전의 마지막 단계에서 재귀 호출을 수행하면**, 컴파일러나 인터프리터가 이를 최적화하여 반복과 비슷한 공간 효율을 얻을 수 있다. 이런 경우를 꼬리 재귀(tail recursion)라고 한다. + +- **일반 재귀**: 함수가 이전 단계의 함수로 반환된 뒤에도 실행할 코드가 남아 있으므로, 시스템은 이전 호출의 문맥을 저장해야 한다. +- **꼬리 재귀**: 재귀 호출이 함수 반환 전의 마지막 연산이다. 따라서 이전 단계로 반환된 뒤에 다른 연산을 수행할 필요가 없으므로, 시스템은 이전 함수의 문맥을 저장하지 않아도 된다. + +$1 + 2 + \dots + n$을 계산하는 예에서 결과 변수 `res`를 함수 매개변수로 두면 꼬리 재귀를 구현할 수 있다. + +=== "Python" + + ```python title="recursion.py" + def tail_recur(n, res): + """꼬리 재귀""" + # 종료 조건 + if n == 0: + return res + # 꼬리 재귀 호출 + return tail_recur(n - 1, res + n) + ``` + +=== "C++" + + ```cpp title="recursion.cpp" + /* 꼬리 재귀 */ + int tailRecur(int n, int res) { + // 종료 조건 + if (n == 0) + return res; + // 꼬리 재귀 호출 + return tailRecur(n - 1, res + n); + } + ``` + +=== "Java" + + ```java title="recursion.java" + /* 꼬리 재귀 */ + int tailRecur(int n, int res) { + // 종료 조건 + if (n == 0) + return res; + // 꼬리 재귀 호출 + return tailRecur(n - 1, res + n); + } + ``` + +=== "C#" + + ```csharp title="recursion.cs" + /* 꼬리 재귀 */ + int TailRecur(int n, int res) { + // 종료 조건 + if (n == 0) + return res; + // 꼬리 재귀 호출 + return TailRecur(n - 1, res + n); + } + ``` + +=== "Go" + + ```go title="recursion.go" + /* 꼬리 재귀 */ + func tailRecur(n int, res int) int { + // 종료 조건 + if n == 0 { + return res + } + // 꼬리 재귀 호출 + return tailRecur(n-1, res+n) + } + ``` + +=== "Swift" + + ```swift title="recursion.swift" + /* 꼬리 재귀 */ + func tailRecur(n: Int, res: Int) -> Int { + // 종료 조건 + if n == 0 { + return res + } + // 꼬리 재귀 호출 + return tailRecur(n: n - 1, res: res + n) + } + ``` + +=== "JS" + + ```javascript title="recursion.js" + /* 꼬리 재귀 */ + function tailRecur(n, res) { + // 종료 조건 + if (n === 0) return res; + // 꼬리 재귀 호출 + return tailRecur(n - 1, res + n); + } + ``` + +=== "TS" + + ```typescript title="recursion.ts" + /* 꼬리 재귀 */ + function tailRecur(n: number, res: number): number { + // 종료 조건 + if (n === 0) return res; + // 꼬리 재귀 호출 + return tailRecur(n - 1, res + n); + } + ``` + +=== "Dart" + + ```dart title="recursion.dart" + /* 꼬리 재귀 */ + int tailRecur(int n, int res) { + // 종료 조건 + if (n == 0) return res; + // 꼬리 재귀 호출 + return tailRecur(n - 1, res + n); + } + ``` + +=== "Rust" + + ```rust title="recursion.rs" + /* 꼬리 재귀 */ + fn tail_recur(n: i32, res: i32) -> i32 { + // 종료 조건 + if n == 0 { + return res; + } + // 꼬리 재귀 호출 + tail_recur(n - 1, res + n) + } + ``` + +=== "C" + + ```c title="recursion.c" + /* 꼬리 재귀 */ + int tailRecur(int n, int res) { + // 종료 조건 + if (n == 0) + return res; + // 꼬리 재귀 호출 + return tailRecur(n - 1, res + n); + } + ``` + +=== "Kotlin" + + ```kotlin title="recursion.kt" + /* 꼬리 재귀 */ + tailrec fun tailRecur(n: Int, res: Int): Int { + // tailrec 키워드를 추가하여 꼬리 재귀 최적화를 활성화한다 + // 종료 조건 + if (n == 0) + return res + // 꼬리 재귀 호출 + return tailRecur(n - 1, res + n) + } + ``` + +=== "Ruby" + + ```ruby title="recursion.rb" + ### 꼬리 재귀 ### + def tail_recur(n, res) + # 종료 조건 + return res if n == 0 + # 꼬리 재귀 호출 + tail_recur(n - 1, res + n) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +꼬리 재귀의 실행 과정은 아래 그림(2-5)과 같다. 일반 재귀와 꼬리 재귀를 비교하면 합을 구하는 시점이 다르다. + +- **일반 재귀**: ‘돌아오기’ 과정에서 합을 구한다. 각 단계가 반환된 뒤에 덧셈을 한 번씩 수행한다. +- **꼬리 재귀**: ‘내려가기’ 과정에서 합을 구한다. ‘돌아오기’ 과정에서는 단계별로 반환만 하면 된다. + +![꼬리 재귀 과정](iteration_and_recursion.assets/tail_recursion_sum.png){ class="animation-figure" } + +

그림 2-5   꼬리 재귀 과정

+ +!!! tip "참고" + + 많은 컴파일러와 인터프리터가 꼬리 재귀 최적화를 지원하지 않는다는 점에 주의하자. 예를 들어 Python은 기본적으로 꼬리 재귀 최적화를 지원하지 않으므로, 함수가 꼬리 재귀 형태여도 스택 오버플로 문제가 발생할 수 있다. + +### 3.   재귀 트리 + +‘분할 정복’과 관련된 알고리즘 문제에서는 재귀가 반복보다 생각하기 쉽고 코드도 읽기 쉬운 경우가 많다. ‘피보나치 수열’을 예로 살펴보자. + +!!! question "문제" + + 피보나치 수열 $0, 1, 1, 2, 3, 5, 8, 13, \dots$가 주어졌을 때, 이 수열의 $n$번째 수를 구하라. + +피보나치 수열의 $n$번째 수를 $f(n)$이라고 하면 다음 두 가지 사실을 쉽게 알 수 있다. + +- 수열의 첫 두 수는 $f(1) = 0$과 $f(2) = 1$이다. +- 수열의 각 수는 앞의 두 수의 합이다. 즉, $f(n) = f(n - 1) + f(n - 2)$이다. + +점화 관계에 따라 재귀 호출을 하고 첫 두 수를 종료 조건으로 삼으면 재귀 코드를 작성할 수 있다. `fib(n)`을 호출하면 피보나치 수열의 $n$번째 수를 얻는다. + +=== "Python" + + ```python title="recursion.py" + def fib(n: int) -> int: + """피보나치 수열: 재귀""" + # 종료 조건 f(1) = 0, f(2) = 1 + if n == 1 or n == 2: + return n - 1 + # 재귀 호출 f(n) = f(n-1) + f(n-2) + res = fib(n - 1) + fib(n - 2) + # 결과 f(n)을 반환한다 + return res + ``` + +=== "C++" + + ```cpp title="recursion.cpp" + /* 피보나치 수열: 재귀 */ + int fib(int n) { + // 종료 조건 f(1) = 0, f(2) = 1 + if (n == 1 || n == 2) + return n - 1; + // 재귀 호출 f(n) = f(n-1) + f(n-2) + int res = fib(n - 1) + fib(n - 2); + // 결과 f(n)을 반환한다 + return res; + } + ``` + +=== "Java" + + ```java title="recursion.java" + /* 피보나치 수열: 재귀 */ + int fib(int n) { + // 종료 조건 f(1) = 0, f(2) = 1 + if (n == 1 || n == 2) + return n - 1; + // 재귀 호출 f(n) = f(n-1) + f(n-2) + int res = fib(n - 1) + fib(n - 2); + // 결과 f(n)을 반환한다 + return res; + } + ``` + +=== "C#" + + ```csharp title="recursion.cs" + /* 피보나치 수열: 재귀 */ + int Fib(int n) { + // 종료 조건 f(1) = 0, f(2) = 1 + if (n == 1 || n == 2) + return n - 1; + // 재귀 호출 f(n) = f(n-1) + f(n-2) + int res = Fib(n - 1) + Fib(n - 2); + // 결과 f(n)을 반환한다 + return res; + } + ``` + +=== "Go" + + ```go title="recursion.go" + /* 피보나치 수열: 재귀 */ + func fib(n int) int { + // 종료 조건 f(1) = 0, f(2) = 1 + if n == 1 || n == 2 { + return n - 1 + } + // 재귀 호출 f(n) = f(n-1) + f(n-2) + res := fib(n-1) + fib(n-2) + // 결과 f(n)을 반환한다 + return res + } + ``` + +=== "Swift" + + ```swift title="recursion.swift" + /* 피보나치 수열: 재귀 */ + func fib(n: Int) -> Int { + // 종료 조건 f(1) = 0, f(2) = 1 + if n == 1 || n == 2 { + return n - 1 + } + // 재귀 호출 f(n) = f(n-1) + f(n-2) + let res = fib(n: n - 1) + fib(n: n - 2) + // 결과 f(n)을 반환한다 + return res + } + ``` + +=== "JS" + + ```javascript title="recursion.js" + /* 피보나치 수열: 재귀 */ + function fib(n) { + // 종료 조건 f(1) = 0, f(2) = 1 + if (n === 1 || n === 2) return n - 1; + // 재귀 호출 f(n) = f(n-1) + f(n-2) + const res = fib(n - 1) + fib(n - 2); + // 결과 f(n)을 반환한다 + return res; + } + ``` + +=== "TS" + + ```typescript title="recursion.ts" + /* 피보나치 수열: 재귀 */ + function fib(n: number): number { + // 종료 조건 f(1) = 0, f(2) = 1 + if (n === 1 || n === 2) return n - 1; + // 재귀 호출 f(n) = f(n-1) + f(n-2) + const res = fib(n - 1) + fib(n - 2); + // 결과 f(n)을 반환한다 + return res; + } + ``` + +=== "Dart" + + ```dart title="recursion.dart" + /* 피보나치 수열: 재귀 */ + int fib(int n) { + // 종료 조건 f(1) = 0, f(2) = 1 + if (n == 1 || n == 2) return n - 1; + // 재귀 호출 f(n) = f(n-1) + f(n-2) + int res = fib(n - 1) + fib(n - 2); + // 결과 f(n)을 반환한다 + return res; + } + ``` + +=== "Rust" + + ```rust title="recursion.rs" + /* 피보나치 수열: 재귀 */ + fn fib(n: i32) -> i32 { + // 종료 조건 f(1) = 0, f(2) = 1 + if n == 1 || n == 2 { + return n - 1; + } + // 재귀 호출 f(n) = f(n-1) + f(n-2) + let res = fib(n - 1) + fib(n - 2); + // 결과를 반환한다 + res + } + ``` + +=== "C" + + ```c title="recursion.c" + /* 피보나치 수열: 재귀 */ + int fib(int n) { + // 종료 조건 f(1) = 0, f(2) = 1 + if (n == 1 || n == 2) + return n - 1; + // 재귀 호출 f(n) = f(n-1) + f(n-2) + int res = fib(n - 1) + fib(n - 2); + // 결과 f(n)을 반환한다 + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="recursion.kt" + /* 피보나치 수열: 재귀 */ + fun fib(n: Int): Int { + // 종료 조건 f(1) = 0, f(2) = 1 + if (n == 1 || n == 2) + return n - 1 + // 재귀 호출 f(n) = f(n-1) + f(n-2) + val res = fib(n - 1) + fib(n - 2) + // 결과 f(n)을 반환한다 + return res + } + ``` + +=== "Ruby" + + ```ruby title="recursion.rb" + ### 피보나치 수열: 재귀 ### + def fib(n) + # 종료 조건 f(1) = 0, f(2) = 1 + return n - 1 if n == 1 || n == 2 + # 재귀 호출 f(n) = f(n-1) + f(n-2) + res = fib(n - 1) + fib(n - 2) + # 결과 f(n)을 반환한다 + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +위 코드는 함수 안에서 두 번의 재귀 호출을 수행한다. **이는 하나의 호출에서 두 개의 호출 분기가 생긴다는 뜻이다**. 아래 그림(2-6)과 같이 재귀 호출을 계속하면 최종적으로 $n$개 층으로 이루어진 재귀 트리(recursion tree)가 만들어진다. + +![피보나치 수열의 재귀 트리](iteration_and_recursion.assets/recursion_tree.png){ class="animation-figure" } + +

그림 2-6   피보나치 수열의 재귀 트리

+ +본질적으로 재귀는 ‘문제를 더 작은 부분 문제로 나누는’ 사고방식을 보여 준다. 이러한 분할 정복 전략은 매우 중요하다. + +- 알고리즘 관점에서 탐색, 정렬, 백트래킹, 분할 정복, 동적 계획법 등 중요한 알고리즘 전략 다수가 이 사고방식을 직간접적으로 활용한다. +- 자료 구조 관점에서 재귀는 연결 리스트, 트리, 그래프와 관련된 문제를 다루는 데 자연스럽게 어울린다. 이들은 분할 정복 관점으로 분석하기에 매우 적합하기 때문이다. + +## 2.2.3   두 방식의 비교 + +앞의 내용을 정리하면, 아래 표(2-1)와 같이 반복과 재귀는 구현, 성능, 적용 범위에서 차이가 있다. + +

표 2-1   반복과 재귀의 특징 비교

+ +
+ +| | 반복 | 재귀 | +| -------- | -------------------------------------- | ------------------------------------------------------------ | +| 구현 방식 | 반복 구조 | 함수가 자기 자신을 호출 | +| 시간 효율 | 보통 효율이 높고 함수 호출 비용이 없음 | 함수 호출마다 비용이 발생함 | +| 메모리 사용 | 보통 고정 크기의 메모리 공간을 사용함 | 함수 호출이 누적되면 스택 프레임 공간을 많이 사용할 수 있음 | +| 적합한 문제 | 간단한 반복 작업에 적합하며 코드가 직관적이고 읽기 쉬움 | 트리, 그래프, 분할 정복, 백트래킹 등 부분 문제로 나누는 문제에 적합하며 코드 구조가 간결하고 명확함 | + +
+ +!!! tip "참고" + + 다음 내용이 이해하기 어렵다면 ‘스택’ 장을 읽은 뒤에 다시 복습해도 좋다. + +그렇다면 반복과 재귀는 내부적으로 어떤 관계가 있을까? 앞의 재귀 함수를 예로 들면, 합을 구하는 연산은 재귀의 ‘돌아오기’ 단계에서 수행된다. 즉, 가장 먼저 호출된 함수가 실제로는 가장 나중에 자신의 덧셈을 마친다. **이 작동 방식은 스택의 ‘후입선출’ 원리와 통한다**. + +실제로 ‘호출 스택’, ‘스택 프레임 공간’ 같은 재귀 관련 용어에도 재귀와 스택의 밀접한 관계가 드러난다. + +1. **내려가기**: 함수가 호출되면 시스템은 ‘호출 스택’에 새로운 스택 프레임을 할당하여 함수의 지역 변수, 매개변수, 반환 주소 등의 데이터를 저장한다. +2. **돌아오기**: 함수가 실행을 마치고 반환되면 해당 스택 프레임이 ‘호출 스택’에서 제거되고, 이전 함수의 실행 환경이 복원된다. + +따라서 **명시적인 스택으로 호출 스택의 동작을 재현하면**, 재귀를 반복 형태로 바꿀 수 있다. + +=== "Python" + + ```python title="recursion.py" + def for_loop_recur(n: int) -> int: + """반복으로 재귀 구현""" + # 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + stack = [] + res = 0 + # 내려가기: 재귀 호출 + for i in range(n, 0, -1): + # 스택에 삽입하여 내려가는 과정을 모방한다 + stack.append(i) + # 돌아오기: 결과 반환 + while stack: + # 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.pop() + # res = 1+2+3+...+n + return res + ``` + +=== "C++" + + ```cpp title="recursion.cpp" + /* 반복으로 재귀 구현 */ + int forLoopRecur(int n) { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + stack stack; + int res = 0; + // 내려가기: 재귀 호출 + for (int i = n; i > 0; i--) { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.push(i); + } + // 돌아오기: 결과 반환 + while (!stack.empty()) { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.top(); + stack.pop(); + } + // res = 1+2+3+...+n + return res; + } + ``` + +=== "Java" + + ```java title="recursion.java" + /* 반복으로 재귀 구현 */ + int forLoopRecur(int n) { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + Stack stack = new Stack<>(); + int res = 0; + // 내려가기: 재귀 호출 + for (int i = n; i > 0; i--) { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.push(i); + } + // 돌아오기: 결과 반환 + while (!stack.isEmpty()) { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.pop(); + } + // res = 1+2+3+...+n + return res; + } + ``` + +=== "C#" + + ```csharp title="recursion.cs" + /* 반복으로 재귀 구현 */ + int ForLoopRecur(int n) { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + Stack stack = new(); + int res = 0; + // 내려가기: 재귀 호출 + for (int i = n; i > 0; i--) { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.Push(i); + } + // 돌아오기: 결과 반환 + while (stack.Count > 0) { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.Pop(); + } + // res = 1+2+3+...+n + return res; + } + ``` + +=== "Go" + + ```go title="recursion.go" + /* 반복으로 재귀 구현 */ + func forLoopRecur(n int) int { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + stack := list.New() + res := 0 + // 내려가기: 재귀 호출 + for i := n; i > 0; i-- { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.PushBack(i) + } + // 돌아오기: 결과 반환 + for stack.Len() != 0 { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.Back().Value.(int) + stack.Remove(stack.Back()) + } + // res = 1+2+3+...+n + return res + } + ``` + +=== "Swift" + + ```swift title="recursion.swift" + /* 반복으로 재귀 구현 */ + func forLoopRecur(n: Int) -> Int { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + var stack: [Int] = [] + var res = 0 + // 내려가기: 재귀 호출 + for i in (1 ... n).reversed() { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.append(i) + } + // 돌아오기: 결과 반환 + while !stack.isEmpty { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.removeLast() + } + // res = 1+2+3+...+n + return res + } + ``` + +=== "JS" + + ```javascript title="recursion.js" + /* 반복으로 재귀 구현 */ + function forLoopRecur(n) { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + const stack = []; + let res = 0; + // 내려가기: 재귀 호출 + for (let i = n; i > 0; i--) { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.push(i); + } + // 돌아오기: 결과 반환 + while (stack.length) { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.pop(); + } + // res = 1+2+3+...+n + return res; + } + ``` + +=== "TS" + + ```typescript title="recursion.ts" + /* 반복으로 재귀 구현 */ + function forLoopRecur(n: number): number { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + const stack: number[] = []; + let res: number = 0; + // 내려가기: 재귀 호출 + for (let i = n; i > 0; i--) { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.push(i); + } + // 돌아오기: 결과 반환 + while (stack.length) { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.pop(); + } + // res = 1+2+3+...+n + return res; + } + ``` + +=== "Dart" + + ```dart title="recursion.dart" + /* 반복으로 재귀 구현 */ + int forLoopRecur(int n) { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + List stack = []; + int res = 0; + // 내려가기: 재귀 호출 + for (int i = n; i > 0; i--) { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.add(i); + } + // 돌아오기: 결과 반환 + while (!stack.isEmpty) { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.removeLast(); + } + // res = 1+2+3+...+n + return res; + } + ``` + +=== "Rust" + + ```rust title="recursion.rs" + /* 반복으로 재귀 구현 */ + fn for_loop_recur(n: i32) -> i32 { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + let mut stack = Vec::new(); + let mut res = 0; + // 내려가기: 재귀 호출 + for i in (1..=n).rev() { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.push(i); + } + // 돌아오기: 결과 반환 + while !stack.is_empty() { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.pop().unwrap(); + } + // res = 1+2+3+...+n + res + } + ``` + +=== "C" + + ```c title="recursion.c" + /* 반복으로 재귀 구현 */ + int forLoopRecur(int n) { + int stack[1000]; // 큰 배열을 사용하여 스택을 구현한다 + int top = -1; // 스택의 맨 위 인덱스 + int res = 0; + // 내려가기: 재귀 호출 + for (int i = n; i > 0; i--) { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack[1 + top++] = i; + } + // 돌아오기: 결과 반환 + while (top >= 0) { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack[top--]; + } + // res = 1+2+3+...+n + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="recursion.kt" + /* 반복으로 재귀 구현 */ + fun forLoopRecur(n: Int): Int { + // 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + val stack = Stack() + var res = 0 + // 내려가기: 재귀 호출 + for (i in n downTo 0) { + // 스택에 삽입하여 내려가는 과정을 모방한다 + stack.push(i) + } + // 돌아오기: 결과 반환 + while (stack.isNotEmpty()) { + // 스택에서 꺼내어 돌아오는 과정을 모방한다 + res += stack.pop() + } + // res = 1+2+3+...+n + return res + } + ``` + +=== "Ruby" + + ```ruby title="recursion.rb" + ### 반복으로 재귀 구현 ### + def for_loop_recur(n) + # 명시적인 스택을 사용하여 시스템 호출 스택을 모방한다 + stack = [] + res = 0 + + # 내려가기: 재귀 호출 + for i in n.downto(0) + # 스택에 삽입하여 내려가는 과정을 모방한다 + stack << i + end + # 돌아오기: 결과 반환 + while !stack.empty? + res += stack.pop + end + + # res = 1+2+3+...+n + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +위 코드를 보면 재귀를 반복으로 바꾸자 코드가 더 복잡해졌다. 반복과 재귀는 많은 경우 서로 변환할 수 있지만, 다음 두 가지 이유로 항상 그럴 가치가 있는 것은 아니다. + +- 변환한 코드가 더 이해하기 어렵고 가독성이 떨어질 수 있다. +- 복잡한 문제에서는 시스템 호출 스택의 동작을 재현하기가 매우 어려울 수 있다. + +결국 **반복과 재귀 중 어떤 것을 선택할지는 문제의 구체적인 성격에 달려 있다**. 실제 프로그래밍에서는 둘의 장단점을 비교하고 상황에 맞는 방법을 선택하는 것이 중요하다. diff --git a/ko/docs/chapter_computational_complexity/performance_evaluation.md b/ko/docs/chapter_computational_complexity/performance_evaluation.md new file mode 100644 index 000000000..6cf04958a --- /dev/null +++ b/ko/docs/chapter_computational_complexity/performance_evaluation.md @@ -0,0 +1,53 @@ +--- +comments: true +--- + +# 2.1   알고리즘 효율 평가 + +알고리즘을 설계할 때는 다음 두 단계의 목표를 차례로 추구한다. + +1. **문제의 해법 찾기**: 알고리즘은 정해진 입력 범위 안에서 문제의 올바른 해를 안정적으로 구해야 한다. +2. **최적의 해법 찾기**: 같은 문제에도 여러 해법이 있을 수 있으므로, 가능한 한 효율적인 알고리즘을 찾고자 한다. + +즉, 문제를 해결할 수 있다는 전제하에 알고리즘 효율은 알고리즘의 우수성을 평가하는 주요 지표가 된다. 여기에는 다음 두 가지 측면이 있다. + +- **시간 효율**: 알고리즘의 실행 시간이 얼마나 긴가. +- **공간 효율**: 알고리즘이 차지하는 메모리 공간이 얼마나 큰가. + +간단히 말해, **우리의 목표는 ‘빠르면서도 메모리를 적게 사용하는’ 자료 구조와 알고리즘을 설계하는 것이다**. 알고리즘 효율을 효과적으로 평가하는 일도 매우 중요하다. 그래야 여러 알고리즘을 비교하고, 이를 바탕으로 알고리즘을 설계하고 최적화할 수 있기 때문이다. + +효율 평가 방법은 크게 실제 측정과 이론적 추정으로 나뉜다. + +## 2.1.1   실제 측정 + +같은 문제를 해결할 수 있는 알고리즘 `A`와 알고리즘 `B`가 있으며, 두 알고리즘의 효율을 비교해야 한다고 하자. 가장 직접적인 방법은 컴퓨터에서 두 알고리즘을 실행하고 실행 시간과 메모리 사용량을 관찰해 기록하는 것이다. 이러한 평가 방식은 실제 상황을 반영할 수 있지만 한계도 크다. + +한편으로는 **측정 환경에 따른 영향을 배제하기 어렵다**. 하드웨어 구성은 알고리즘 성능에 영향을 준다. 예를 들어 병렬성이 높은 알고리즘은 멀티코어 CPU에서 실행하기에 더 적합하고, 메모리 연산이 집중된 알고리즘은 고성능 메모리에서 더 좋은 성능을 보인다. 즉, 같은 알고리즘도 컴퓨터에 따라 측정 결과가 다를 수 있다. 따라서 다양한 컴퓨터에서 측정해 평균 효율을 구해야 하지만, 이는 현실적이지 않다. + +다른 한편으로는 **충분한 측정을 수행하는 데 매우 많은 자원이 든다**. 입력 데이터의 양이 달라지면 알고리즘의 효율도 달라진다. 예를 들어 입력 데이터가 적을 때는 알고리즘 `A`의 실행 시간이 알고리즘 `B`보다 짧지만, 입력 데이터가 많을 때는 결과가 정반대일 수 있다. 따라서 설득력 있는 결론을 얻으려면 다양한 크기의 입력 데이터를 측정해야 하며, 여기에는 많은 컴퓨팅 자원이 필요하다. + +## 2.1.2   이론적 추정 + +실제 측정에는 큰 한계가 있으므로, 계산만으로 알고리즘 효율을 평가하는 방법을 생각해 볼 수 있다. 이러한 추정 방법을 점근적 복잡도 분석(asymptotic complexity analysis), 줄여서 복잡도 분석이라고 한다. + +복잡도 분석은 알고리즘 실행에 필요한 시간 및 공간 자원과 입력 데이터 크기의 관계를 나타낸다. **입력 데이터 크기가 증가할 때 알고리즘 실행에 필요한 시간과 공간이 증가하는 추세를 설명한다**. 이 정의는 다소 이해하기 어려우므로 세 가지 핵심으로 나누어 살펴보자. + +- ‘시간 및 공간 자원’은 각각 시간 복잡도(time complexity)와 공간 복잡도(space complexity)에 해당한다. +- ‘입력 데이터 크기가 증가할 때’는 복잡도가 알고리즘의 실행 효율과 입력 데이터 크기 사이의 관계를 반영한다는 뜻이다. +- ‘시간과 공간이 증가하는 추세’는 복잡도 분석이 실행 시간이나 사용 공간의 구체적인 값보다 시간 또는 공간이 얼마나 빠르게 증가하는지에 주목한다는 뜻이다. + +**복잡도 분석은 실제 측정 방법의 단점을 극복한다**. 구체적으로 다음과 같은 장점이 있다. + +- 실제로 코드를 실행하지 않아도 되므로 에너지를 절약하고 환경 부담을 줄인다. +- 측정 환경에 의존하지 않아 분석 결과를 모든 실행 플랫폼에 적용할 수 있다. +- 데이터 양에 따른 알고리즘 효율, 특히 데이터가 많을 때의 알고리즘 성능을 보여 준다. + +!!! tip "참고" + + 복잡도 개념이 아직 혼란스러워도 걱정할 필요 없다. 이후 절에서 자세히 소개한다. + +복잡도 분석은 알고리즘 효율을 평가하는 ‘자’와 같다. 이를 통해 알고리즘 실행에 필요한 시간과 공간 자원을 가늠하고 여러 알고리즘의 효율을 비교할 수 있다. + +복잡도는 수학적 개념이므로 초보자에게는 추상적으로 느껴지고 배우기도 다소 어려울 수 있다. 이런 관점에서는 복잡도 분석이 맨 처음 소개하기에 적절하지 않을 수도 있다. 그러나 어떤 자료 구조나 알고리즘의 특징을 논할 때는 실행 속도와 공간 사용량에 대한 분석을 피하기 어렵다. + +따라서 자료 구조와 알고리즘을 깊이 학습하기 전에, **복잡도 분석을 기초적으로 이해하여 간단한 알고리즘의 복잡도를 분석할 수 있도록 하는 것을 권한다**. diff --git a/ko/docs/chapter_computational_complexity/space_complexity.md b/ko/docs/chapter_computational_complexity/space_complexity.md new file mode 100644 index 000000000..52e348416 --- /dev/null +++ b/ko/docs/chapter_computational_complexity/space_complexity.md @@ -0,0 +1,2247 @@ +--- +comments: true +--- + +# 2.4   공간 복잡도 + +공간 복잡도(space complexity)는 데이터 양이 많아질 때 알고리즘이 차지하는 메모리 공간이 증가하는 추세를 측정한다. 시간 복잡도와 매우 비슷한 개념으로, ‘실행 시간’을 ‘차지하는 메모리 공간’으로 바꾸면 된다. + +## 2.4.1   알고리즘과 관련된 공간 + +알고리즘이 실행 중 사용하는 메모리 공간에는 주로 다음과 같은 종류가 있다. + +- **입력 공간**: 알고리즘의 입력 데이터를 저장한다. +- **임시 공간**: 실행 중의 변수, 객체, 함수 문맥 등의 데이터를 저장한다. +- **출력 공간**: 알고리즘의 출력 데이터를 저장한다. + +일반적으로 공간 복잡도에 포함하는 범위는 ‘임시 공간’과 ‘출력 공간’의 합이다. + +임시 공간은 다시 세 부분으로 나눌 수 있다. + +- **임시 데이터**: 실행 중의 각종 상수, 변수, 객체 등을 저장한다. +- **스택 프레임 공간**: 호출한 함수의 문맥 데이터를 저장한다. 시스템은 함수가 호출될 때마다 스택 맨 위에 스택 프레임을 만들며, 함수가 반환되면 이 공간을 해제한다. +- **명령어 공간**: 컴파일된 프로그램 명령어를 저장하며, 실제 계산에서는 보통 무시한다. + +프로그램의 공간 복잡도를 분석할 때는 아래 그림(2-15)과 같이 **보통 임시 데이터, 스택 프레임 공간, 출력 데이터의 세 부분을 계산한다**. + +![알고리즘이 사용하는 공간](space_complexity.assets/space_types.png){ class="animation-figure" } + +

그림 2-15   알고리즘이 사용하는 공간

+ +관련 코드는 다음과 같다. + +=== "Python" + + ```python title="" + class Node: + """클래스""" + def __init__(self, x: int): + self.val: int = x # 노드 값 + self.next: Node | None = None # 다음 노드를 가리키는 참조 + + def function() -> int: + """함수""" + # 몇 가지 연산 수행... + return 0 + + def algorithm(n) -> int: # 입력 데이터 + A = 0 # 임시 데이터(상수, 보통 대문자로 표기) + b = 0 # 임시 데이터(변수) + node = Node(0) # 임시 데이터(객체) + c = function() # 스택 프레임 공간(함수 호출) + return A + b + c # 출력 데이터 + ``` + +=== "C++" + + ```cpp title="" + /* 구조체 */ + struct Node { + int val; + Node *next; + Node(int x) : val(x), next(nullptr) {} + }; + + /* 함수 */ + int func() { + // 몇 가지 연산 수행... + return 0; + } + + int algorithm(int n) { // 입력 데이터 + const int a = 0; // 임시 데이터(상수) + int b = 0; // 임시 데이터(변수) + Node* node = new Node(0); // 임시 데이터(객체) + int c = func(); // 스택 프레임 공간(함수 호출) + return a + b + c; // 출력 데이터 + } + ``` + +=== "Java" + + ```java title="" + /* 클래스 */ + class Node { + int val; + Node next; + Node(int x) { val = x; } + } + + /* 함수 */ + int function() { + // 몇 가지 연산 수행... + return 0; + } + + int algorithm(int n) { // 입력 데이터 + final int a = 0; // 임시 데이터(상수) + int b = 0; // 임시 데이터(변수) + Node node = new Node(0); // 임시 데이터(객체) + int c = function(); // 스택 프레임 공간(함수 호출) + return a + b + c; // 출력 데이터 + } + ``` + +=== "C#" + + ```csharp title="" + /* 클래스 */ + class Node(int x) { + int val = x; + Node next; + } + + /* 함수 */ + int Function() { + // 몇 가지 연산 수행... + return 0; + } + + int Algorithm(int n) { // 입력 데이터 + const int a = 0; // 임시 데이터(상수) + int b = 0; // 임시 데이터(변수) + Node node = new(0); // 임시 데이터(객체) + int c = Function(); // 스택 프레임 공간(함수 호출) + return a + b + c; // 출력 데이터 + } + ``` + +=== "Go" + + ```go title="" + /* 구조체 */ + type node struct { + val int + next *node + } + + /* node 구조체 생성 */ + func newNode(val int) *node { + return &node{val: val} + } + + /* 함수 */ + func function() int { + // 몇 가지 연산 수행... + return 0 + } + + func algorithm(n int) int { // 입력 데이터 + const a = 0 // 임시 데이터(상수) + b := 0 // 임시 데이터(변수) + newNode(0) // 임시 데이터(객체) + c := function() // 스택 프레임 공간(함수 호출) + return a + b + c // 출력 데이터 + } + ``` + +=== "Swift" + + ```swift title="" + /* 클래스 */ + class Node { + var val: Int + var next: Node? + + init(x: Int) { + val = x + } + } + + /* 함수 */ + func function() -> Int { + // 몇 가지 연산 수행... + return 0 + } + + func algorithm(n: Int) -> Int { // 입력 데이터 + let a = 0 // 임시 데이터(상수) + var b = 0 // 임시 데이터(변수) + let node = Node(x: 0) // 임시 데이터(객체) + let c = function() // 스택 프레임 공간(함수 호출) + return a + b + c // 출력 데이터 + } + ``` + +=== "JS" + + ```javascript title="" + /* 클래스 */ + class Node { + val; + next; + constructor(val) { + this.val = val === undefined ? 0 : val; // 노드 값 + this.next = null; // 다음 노드를 가리키는 참조 + } + } + + /* 함수 */ + function constFunc() { + // 몇 가지 연산 수행 + return 0; + } + + function algorithm(n) { // 입력 데이터 + const a = 0; // 임시 데이터(상수) + let b = 0; // 임시 데이터(변수) + const node = new Node(0); // 임시 데이터(객체) + const c = constFunc(); // 스택 프레임 공간(함수 호출) + return a + b + c; // 출력 데이터 + } + ``` + +=== "TS" + + ```typescript title="" + /* 클래스 */ + class Node { + val: number; + next: Node | null; + constructor(val?: number) { + this.val = val === undefined ? 0 : val; // 노드 값 + this.next = null; // 다음 노드를 가리키는 참조 + } + } + + /* 함수 */ + function constFunc(): number { + // 몇 가지 연산 수행 + return 0; + } + + function algorithm(n: number): number { // 입력 데이터 + const a = 0; // 임시 데이터(상수) + let b = 0; // 임시 데이터(변수) + const node = new Node(0); // 임시 데이터(객체) + const c = constFunc(); // 스택 프레임 공간(함수 호출) + return a + b + c; // 출력 데이터 + } + ``` + +=== "Dart" + + ```dart title="" + /* 클래스 */ + class Node { + int val; + Node next; + Node(this.val, [this.next]); + } + + /* 함수 */ + int function() { + // 몇 가지 연산 수행... + return 0; + } + + int algorithm(int n) { // 입력 데이터 + const int a = 0; // 임시 데이터(상수) + int b = 0; // 임시 데이터(변수) + Node node = Node(0); // 임시 데이터(객체) + int c = function(); // 스택 프레임 공간(함수 호출) + return a + b + c; // 출력 데이터 + } + ``` + +=== "Rust" + + ```rust title="" + use std::rc::Rc; + use std::cell::RefCell; + + /* 구조체 */ + struct Node { + val: i32, + next: Option>>, + } + + /* Node 구조체 생성 */ + impl Node { + fn new(val: i32) -> Self { + Self { val: val, next: None } + } + } + + /* 함수 */ + fn function() -> i32 { + // 몇 가지 연산 수행... + return 0; + } + + fn algorithm(n: i32) -> i32 { // 입력 데이터 + const a: i32 = 0; // 임시 데이터(상수) + let mut b = 0; // 임시 데이터(변수) + let node = Node::new(0); // 임시 데이터(객체) + let c = function(); // 스택 프레임 공간(함수 호출) + return a + b + c; // 출력 데이터 + } + ``` + +=== "C" + + ```c title="" + /* 함수 */ + int func() { + // 몇 가지 연산 수행... + return 0; + } + + int algorithm(int n) { // 입력 데이터 + const int a = 0; // 임시 데이터(상수) + int b = 0; // 임시 데이터(변수) + int c = func(); // 스택 프레임 공간(함수 호출) + return a + b + c; // 출력 데이터 + } + ``` + +=== "Kotlin" + + ```kotlin title="" + /* 클래스 */ + class Node(var _val: Int) { + var next: Node? = null + } + + /* 함수 */ + fun function(): Int { + // 몇 가지 연산 수행... + return 0 + } + + fun algorithm(n: Int): Int { // 입력 데이터 + val a = 0 // 임시 데이터(상수) + var b = 0 // 임시 데이터(변수) + val node = Node(0) // 임시 데이터(객체) + val c = function() // 스택 프레임 공간(함수 호출) + return a + b + c // 출력 데이터 + } + ``` + +=== "Ruby" + + ```ruby title="" + ### 클래스 ### + class Node + attr_accessor :val # 노드 값 + attr_accessor :next # 다음 노드를 가리키는 참조 + + def initialize(x) + @val = x + end + end + + ### 함수 ### + def function + # 몇 가지 연산 수행... + 0 + end + + ### 알고리즘 ### + def algorithm(n) # 입력 데이터 + a = 0 # 임시 데이터(상수) + b = 0 # 임시 데이터(변수) + node = Node.new(0) # 임시 데이터(객체) + c = function # 스택 프레임 공간(함수 호출) + a + b + c # 출력 데이터 + end + ``` + +## 2.4.2   계산 방법 + +공간 복잡도를 구하는 방법은 시간 복잡도와 대체로 같다. 계산 대상을 ‘연산 횟수’에서 ‘사용 공간의 크기’로 바꾸면 된다. + +시간 복잡도와 달리 **보통 최악 공간 복잡도에만 관심을 둔다**. 메모리 공간은 반드시 충족해야 하는 요구 사항이므로, 어떤 입력 데이터에서도 충분한 메모리 공간이 확보되어 있어야 하기 때문이다. + +다음 코드를 보면 최악 공간 복잡도에서 ‘최악’은 두 가지 의미를 지닌다. + +1. **가장 불리한 입력 데이터를 기준으로 한다**: $n < 10$이면 공간 복잡도는 $O(1)$이다. 그러나 $n > 10$이면 초기화한 배열 `nums`가 $O(n)$ 공간을 차지하므로 최악 공간 복잡도는 $O(n)$이다. +2. **알고리즘 실행 중 최대 메모리 사용량을 기준으로 한다**: 예를 들어 프로그램이 마지막 줄을 실행하기 전에는 $O(1)$ 공간을 차지하지만, 배열 `nums`를 초기화하면 $O(n)$ 공간을 차지한다. 따라서 최악 공간 복잡도는 $O(n)$이다. + +=== "Python" + + ```python title="" + def algorithm(n: int): + a = 0 # O(1) + b = [0] * 10000 # O(1) + if n > 10: + nums = [0] * n # O(n) + ``` + +=== "C++" + + ```cpp title="" + void algorithm(int n) { + int a = 0; // O(1) + vector b(10000); // O(1) + if (n > 10) + vector nums(n); // O(n) + } + ``` + +=== "Java" + + ```java title="" + void algorithm(int n) { + int a = 0; // O(1) + int[] b = new int[10000]; // O(1) + if (n > 10) + int[] nums = new int[n]; // O(n) + } + ``` + +=== "C#" + + ```csharp title="" + void Algorithm(int n) { + int a = 0; // O(1) + int[] b = new int[10000]; // O(1) + if (n > 10) { + int[] nums = new int[n]; // O(n) + } + } + ``` + +=== "Go" + + ```go title="" + func algorithm(n int) { + a := 0 // O(1) + b := make([]int, 10000) // O(1) + var nums []int + if n > 10 { + nums := make([]int, n) // O(n) + } + fmt.Println(a, b, nums) + } + ``` + +=== "Swift" + + ```swift title="" + func algorithm(n: Int) { + let a = 0 // O(1) + let b = Array(repeating: 0, count: 10000) // O(1) + if n > 10 { + let nums = Array(repeating: 0, count: n) // O(n) + } + } + ``` + +=== "JS" + + ```javascript title="" + function algorithm(n) { + const a = 0; // O(1) + const b = new Array(10000); // O(1) + if (n > 10) { + const nums = new Array(n); // O(n) + } + } + ``` + +=== "TS" + + ```typescript title="" + function algorithm(n: number): void { + const a = 0; // O(1) + const b = new Array(10000); // O(1) + if (n > 10) { + const nums = new Array(n); // O(n) + } + } + ``` + +=== "Dart" + + ```dart title="" + void algorithm(int n) { + int a = 0; // O(1) + List b = List.filled(10000, 0); // O(1) + if (n > 10) { + List nums = List.filled(n, 0); // O(n) + } + } + ``` + +=== "Rust" + + ```rust title="" + fn algorithm(n: i32) { + let a = 0; // O(1) + let b = [0; 10000]; // O(1) + if n > 10 { + let nums = vec![0; n as usize]; // O(n) + } + } + ``` + +=== "C" + + ```c title="" + void algorithm(int n) { + int a = 0; // O(1) + int b[10000]; // O(1) + if (n > 10) + int nums[n] = {0}; // O(n) + } + ``` + +=== "Kotlin" + + ```kotlin title="" + fun algorithm(n: Int) { + val a = 0 // O(1) + val b = IntArray(10000) // O(1) + if (n > 10) { + val nums = IntArray(n) // O(n) + } + } + ``` + +=== "Ruby" + + ```ruby title="" + def algorithm(n) + a = 0 # O(1) + b = Array.new(10000) # O(1) + nums = Array.new(n) if n > 10 # O(n) + end + ``` + +**재귀 함수에서는 스택 프레임 공간을 계산하는 데 주의해야 한다**. 다음 코드를 살펴보자. + +=== "Python" + + ```python title="" + def function() -> int: + # 몇 가지 연산 수행 + return 0 + + def loop(n: int): + """반복문의 공간 복잡도는 O(1)""" + for _ in range(n): + function() + + def recur(n: int): + """재귀의 공간 복잡도는 O(n)""" + if n == 1: + return + return recur(n - 1) + ``` + +=== "C++" + + ```cpp title="" + int func() { + // 몇 가지 연산 수행 + return 0; + } + /* 반복문의 공간 복잡도는 O(1) */ + void loop(int n) { + for (int i = 0; i < n; i++) { + func(); + } + } + /* 재귀의 공간 복잡도는 O(n) */ + void recur(int n) { + if (n == 1) return; + recur(n - 1); + } + ``` + +=== "Java" + + ```java title="" + int function() { + // 몇 가지 연산 수행 + return 0; + } + /* 반복문의 공간 복잡도는 O(1) */ + void loop(int n) { + for (int i = 0; i < n; i++) { + function(); + } + } + /* 재귀의 공간 복잡도는 O(n) */ + void recur(int n) { + if (n == 1) return; + recur(n - 1); + } + ``` + +=== "C#" + + ```csharp title="" + int Function() { + // 몇 가지 연산 수행 + return 0; + } + /* 반복문의 공간 복잡도는 O(1) */ + void Loop(int n) { + for (int i = 0; i < n; i++) { + Function(); + } + } + /* 재귀의 공간 복잡도는 O(n) */ + int Recur(int n) { + if (n == 1) return 1; + return Recur(n - 1); + } + ``` + +=== "Go" + + ```go title="" + func function() int { + // 몇 가지 연산 수행 + return 0 + } + + /* 반복문의 공간 복잡도는 O(1) */ + func loop(n int) { + for i := 0; i < n; i++ { + function() + } + } + + /* 재귀의 공간 복잡도는 O(n) */ + func recur(n int) { + if n == 1 { + return + } + recur(n - 1) + } + ``` + +=== "Swift" + + ```swift title="" + @discardableResult + func function() -> Int { + // 몇 가지 연산 수행 + return 0 + } + + /* 반복문의 공간 복잡도는 O(1) */ + func loop(n: Int) { + for _ in 0 ..< n { + function() + } + } + + /* 재귀의 공간 복잡도는 O(n) */ + func recur(n: Int) { + if n == 1 { + return + } + recur(n: n - 1) + } + ``` + +=== "JS" + + ```javascript title="" + function constFunc() { + // 몇 가지 연산 수행 + return 0; + } + /* 반복문의 공간 복잡도는 O(1) */ + function loop(n) { + for (let i = 0; i < n; i++) { + constFunc(); + } + } + /* 재귀의 공간 복잡도는 O(n) */ + function recur(n) { + if (n === 1) return; + return recur(n - 1); + } + ``` + +=== "TS" + + ```typescript title="" + function constFunc(): number { + // 몇 가지 연산 수행 + return 0; + } + /* 반복문의 공간 복잡도는 O(1) */ + function loop(n: number): void { + for (let i = 0; i < n; i++) { + constFunc(); + } + } + /* 재귀의 공간 복잡도는 O(n) */ + function recur(n: number): void { + if (n === 1) return; + return recur(n - 1); + } + ``` + +=== "Dart" + + ```dart title="" + int function() { + // 몇 가지 연산 수행 + return 0; + } + /* 반복문의 공간 복잡도는 O(1) */ + void loop(int n) { + for (int i = 0; i < n; i++) { + function(); + } + } + /* 재귀의 공간 복잡도는 O(n) */ + void recur(int n) { + if (n == 1) return; + recur(n - 1); + } + ``` + +=== "Rust" + + ```rust title="" + fn function() -> i32 { + // 몇 가지 연산 수행 + return 0; + } + /* 반복문의 공간 복잡도는 O(1) */ + fn loop(n: i32) { + for i in 0..n { + function(); + } + } + /* 재귀의 공간 복잡도는 O(n) */ + fn recur(n: i32) { + if n == 1 { + return; + } + recur(n - 1); + } + ``` + +=== "C" + + ```c title="" + int func() { + // 몇 가지 연산 수행 + return 0; + } + /* 반복문의 공간 복잡도는 O(1) */ + void loop(int n) { + for (int i = 0; i < n; i++) { + func(); + } + } + /* 재귀의 공간 복잡도는 O(n) */ + void recur(int n) { + if (n == 1) return; + recur(n - 1); + } + ``` + +=== "Kotlin" + + ```kotlin title="" + fun function(): Int { + // 몇 가지 연산 수행 + return 0 + } + /* 반복문의 공간 복잡도는 O(1) */ + fun loop(n: Int) { + for (i in 0.. 그림 2-16   대표적인 공간 복잡도 유형

+ +### 1.   상수 공간 $O(1)$ {data-toc-label="1.   상수 공간"} + +상수 공간은 개수가 입력 데이터 크기 $n$과 무관한 상수, 변수, 객체에서 흔히 나타난다. + +반복문에서 변수를 초기화하거나 함수를 호출할 때 사용하는 메모리는 다음 반복에 들어가면 해제되므로 공간이 누적되지 않는다. 따라서 공간 복잡도는 여전히 $O(1)$이다. + +=== "Python" + + ```python title="space_complexity.py" + def function() -> int: + """함수""" + # 몇 가지 연산을 수행한다 + return 0 + + def constant(n: int): + """상수 차수""" + # 상수, 변수, 객체는 O(1) 공간을 차지한다 + a = 0 + nums = [0] * 10000 + node = ListNode(0) + # 반복문 안의 변수는 O(1) 공간을 차지한다 + for _ in range(n): + c = 0 + # 반복문 안의 함수는 O(1) 공간을 차지한다 + for _ in range(n): + function() + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 함수 */ + int func() { + // 몇 가지 연산을 수행한다 + return 0; + } + + /* 상수 차수 */ + void constant(int n) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + const int a = 0; + int b = 0; + vector nums(10000); + ListNode node(0); + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for (int i = 0; i < n; i++) { + int c = 0; + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for (int i = 0; i < n; i++) { + func(); + } + } + ``` + +=== "Java" + + ```java title="space_complexity.java" + /* 함수 */ + int function() { + // 몇 가지 연산을 수행한다 + return 0; + } + + /* 상수 차수 */ + void constant(int n) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + final int a = 0; + int b = 0; + int[] nums = new int[10000]; + ListNode node = new ListNode(0); + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for (int i = 0; i < n; i++) { + int c = 0; + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for (int i = 0; i < n; i++) { + function(); + } + } + ``` + +=== "C#" + + ```csharp title="space_complexity.cs" + /* 함수 */ + int Function() { + // 몇 가지 연산을 수행한다 + return 0; + } + + /* 상수 차수 */ + void Constant(int n) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + int a = 0; + int b = 0; + int[] nums = new int[10000]; + ListNode node = new(0); + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for (int i = 0; i < n; i++) { + int c = 0; + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for (int i = 0; i < n; i++) { + Function(); + } + } + ``` + +=== "Go" + + ```go title="space_complexity.go" + /* 함수 */ + func function() int { + // 몇 가지 연산을 수행한다... + return 0 + } + + /* 상수 차수 */ + func spaceConstant(n int) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + const a = 0 + b := 0 + nums := make([]int, 10000) + node := newNode(0) + // 반복문 안의 변수는 O(1) 공간을 차지한다 + var c int + for i := 0; i < n; i++ { + c = 0 + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for i := 0; i < n; i++ { + function() + } + b += 0 + c += 0 + nums[0] = 0 + node.val = 0 + } + ``` + +=== "Swift" + + ```swift title="space_complexity.swift" + /* 함수 */ + @discardableResult + func function() -> Int { + // 몇 가지 연산을 수행한다 + return 0 + } + + /* 상수 차수 */ + func constant(n: Int) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + let a = 0 + var b = 0 + let nums = Array(repeating: 0, count: 10000) + let node = ListNode(x: 0) + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for _ in 0 ..< n { + let c = 0 + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for _ in 0 ..< n { + function() + } + } + ``` + +=== "JS" + + ```javascript title="space_complexity.js" + /* 함수 */ + function constFunc() { + // 몇 가지 연산을 수행한다 + return 0; + } + + /* 상수 차수 */ + function constant(n) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + const a = 0; + const b = 0; + const nums = new Array(10000); + const node = new ListNode(0); + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for (let i = 0; i < n; i++) { + const c = 0; + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for (let i = 0; i < n; i++) { + constFunc(); + } + } + ``` + +=== "TS" + + ```typescript title="space_complexity.ts" + /* 함수 */ + function constFunc(): number { + // 몇 가지 연산을 수행한다 + return 0; + } + + /* 상수 차수 */ + function constant(n: number): void { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + const a = 0; + const b = 0; + const nums = new Array(10000); + const node = new ListNode(0); + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for (let i = 0; i < n; i++) { + const c = 0; + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for (let i = 0; i < n; i++) { + constFunc(); + } + } + ``` + +=== "Dart" + + ```dart title="space_complexity.dart" + /* 함수 */ + int function() { + // 몇 가지 연산을 수행한다 + return 0; + } + + /* 상수 차수 */ + void constant(int n) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + final int a = 0; + int b = 0; + List nums = List.filled(10000, 0); + ListNode node = ListNode(0); + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for (var i = 0; i < n; i++) { + int c = 0; + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for (var i = 0; i < n; i++) { + function(); + } + } + ``` + +=== "Rust" + + ```rust title="space_complexity.rs" + /* 함수 */ + fn function() -> i32 { + // 몇 가지 연산을 수행한다 + return 0; + } + + /* 상수 차수 */ + #[allow(unused)] + fn constant(n: i32) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + const A: i32 = 0; + let b = 0; + let nums = vec![0; 10000]; + let node = ListNode::new(0); + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for i in 0..n { + let c = 0; + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for i in 0..n { + function(); + } + } + ``` + +=== "C" + + ```c title="space_complexity.c" + /* 함수 */ + int func() { + // 몇 가지 연산을 수행한다 + return 0; + } + + /* 상수 차수 */ + void constant(int n) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + const int a = 0; + int b = 0; + int nums[1000]; + ListNode *node = newListNode(0); + free(node); + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for (int i = 0; i < n; i++) { + int c = 0; + } + // 반복문 안의 함수는 O(1) 공간을 차지한다 + for (int i = 0; i < n; i++) { + func(); + } + } + ``` + +=== "Kotlin" + + ```kotlin title="space_complexity.kt" + /* 함수 */ + fun function(): Int { + // 몇 가지 연산을 수행한다 + return 0 + } + + /* 상수 차수 */ + fun constant(n: Int) { + // 상수, 변수, 객체는 O(1) 공간을 차지한다 + val a = 0 + var b = 0 + val nums = Array(10000) { 0 } + val node = ListNode(0) + // 반복문 안의 변수는 O(1) 공간을 차지한다 + for (i in 0.. + + +### 2.   선형 공간 $O(n)$ {data-toc-label="2.   선형 공간"} + +선형 공간은 원소 수가 $n$에 비례하는 배열, 연결 리스트, 스택, 큐 등에 흔히 나타난다. + +=== "Python" + + ```python title="space_complexity.py" + def linear(n: int): + """선형 차수""" + # 길이가 n인 리스트는 O(n) 공간을 차지한다 + nums = [0] * n + # 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + hmap = dict[int, str]() + for i in range(n): + hmap[i] = str(i) + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 선형 차수 */ + void linear(int n) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + vector nums(n); + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + vector nodes; + for (int i = 0; i < n; i++) { + nodes.push_back(ListNode(i)); + } + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + unordered_map map; + for (int i = 0; i < n; i++) { + map[i] = to_string(i); + } + } + ``` + +=== "Java" + + ```java title="space_complexity.java" + /* 선형 차수 */ + void linear(int n) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + int[] nums = new int[n]; + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + List nodes = new ArrayList<>(); + for (int i = 0; i < n; i++) { + nodes.add(new ListNode(i)); + } + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + Map map = new HashMap<>(); + for (int i = 0; i < n; i++) { + map.put(i, String.valueOf(i)); + } + } + ``` + +=== "C#" + + ```csharp title="space_complexity.cs" + /* 선형 차수 */ + void Linear(int n) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + int[] nums = new int[n]; + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + List nodes = []; + for (int i = 0; i < n; i++) { + nodes.Add(new ListNode(i)); + } + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + Dictionary map = []; + for (int i = 0; i < n; i++) { + map.Add(i, i.ToString()); + } + } + ``` + +=== "Go" + + ```go title="space_complexity.go" + /* 선형 차수 */ + func spaceLinear(n int) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + _ = make([]int, n) + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + var nodes []*node + for i := 0; i < n; i++ { + nodes = append(nodes, newNode(i)) + } + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + m := make(map[int]string, n) + for i := 0; i < n; i++ { + m[i] = strconv.Itoa(i) + } + } + ``` + +=== "Swift" + + ```swift title="space_complexity.swift" + /* 선형 차수 */ + func linear(n: Int) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + let nums = Array(repeating: 0, count: n) + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + let nodes = (0 ..< n).map { ListNode(x: $0) } + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + let map = Dictionary(uniqueKeysWithValues: (0 ..< n).map { ($0, "\($0)") }) + } + ``` + +=== "JS" + + ```javascript title="space_complexity.js" + /* 선형 차수 */ + function linear(n) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + const nums = new Array(n); + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + const nodes = []; + for (let i = 0; i < n; i++) { + nodes.push(new ListNode(i)); + } + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + const map = new Map(); + for (let i = 0; i < n; i++) { + map.set(i, i.toString()); + } + } + ``` + +=== "TS" + + ```typescript title="space_complexity.ts" + /* 선형 차수 */ + function linear(n: number): void { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + const nums = new Array(n); + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + const nodes: ListNode[] = []; + for (let i = 0; i < n; i++) { + nodes.push(new ListNode(i)); + } + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + const map = new Map(); + for (let i = 0; i < n; i++) { + map.set(i, i.toString()); + } + } + ``` + +=== "Dart" + + ```dart title="space_complexity.dart" + /* 선형 차수 */ + void linear(int n) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + List nums = List.filled(n, 0); + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + List nodes = []; + for (var i = 0; i < n; i++) { + nodes.add(ListNode(i)); + } + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + Map map = HashMap(); + for (var i = 0; i < n; i++) { + map.putIfAbsent(i, () => i.toString()); + } + } + ``` + +=== "Rust" + + ```rust title="space_complexity.rs" + /* 선형 차수 */ + #[allow(unused)] + fn linear(n: i32) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + let mut nums = vec![0; n as usize]; + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + let mut nodes = Vec::new(); + for i in 0..n { + nodes.push(ListNode::new(i)) + } + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + let mut map = HashMap::new(); + for i in 0..n { + map.insert(i, i.to_string()); + } + } + ``` + +=== "C" + + ```c title="space_complexity.c" + /* 해시 테이블 */ + typedef struct { + int key; + int val; + UT_hash_handle hh; // uthash.h를 기반으로 구현한다 + } HashTable; + + /* 선형 차수 */ + void linear(int n) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + int *nums = malloc(sizeof(int) * n); + free(nums); + + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + ListNode **nodes = malloc(sizeof(ListNode *) * n); + for (int i = 0; i < n; i++) { + nodes[i] = newListNode(i); + } + // 메모리 해제 + for (int i = 0; i < n; i++) { + free(nodes[i]); + } + free(nodes); + + // 길이가 n인 해시 테이블은 O(n) 공간을 차지한다 + HashTable *h = NULL; + for (int i = 0; i < n; i++) { + HashTable *tmp = malloc(sizeof(HashTable)); + tmp->key = i; + tmp->val = i; + HASH_ADD_INT(h, key, tmp); + } + + // 메모리 해제 + HashTable *curr, *tmp; + HASH_ITER(hh, h, curr, tmp) { + HASH_DEL(h, curr); + free(curr); + } + } + ``` + +=== "Kotlin" + + ```kotlin title="space_complexity.kt" + /* 선형 차수 */ + fun linear(n: Int) { + // 길이가 n인 배열은 O(n) 공간을 차지한다 + val nums = Array(n) { 0 } + // 길이가 n인 리스트는 O(n) 공간을 차지한다 + val nodes = mutableListOf() + for (i in 0..() + for (i in 0.. + + +아래 그림(2-17)과 같이 이 함수의 재귀 깊이는 $n$이다. 즉, 아직 반환되지 않은 `linear_recur()` 함수가 $n$개 동시에 존재하며 $O(n)$ 크기의 스택 프레임 공간을 사용한다. + +=== "Python" + + ```python title="space_complexity.py" + def linear_recur(n: int): + """선형 차수(재귀 구현)""" + print("재귀 n =", n) + if n == 1: + return + linear_recur(n - 1) + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 선형 차수(재귀 구현) */ + void linearRecur(int n) { + cout << "재귀 n = " << n << endl; + if (n == 1) + return; + linearRecur(n - 1); + } + ``` + +=== "Java" + + ```java title="space_complexity.java" + /* 선형 차수(재귀 구현) */ + void linearRecur(int n) { + System.out.println("재귀 n = " + n); + if (n == 1) + return; + linearRecur(n - 1); + } + ``` + +=== "C#" + + ```csharp title="space_complexity.cs" + /* 선형 차수(재귀 구현) */ + void LinearRecur(int n) { + Console.WriteLine("재귀 n = " + n); + if (n == 1) return; + LinearRecur(n - 1); + } + ``` + +=== "Go" + + ```go title="space_complexity.go" + /* 선형 차수(재귀 구현) */ + func spaceLinearRecur(n int) { + fmt.Println("재귀 n =", n) + if n == 1 { + return + } + spaceLinearRecur(n - 1) + } + ``` + +=== "Swift" + + ```swift title="space_complexity.swift" + /* 선형 차수(재귀 구현) */ + func linearRecur(n: Int) { + print("재귀 n = \(n)") + if n == 1 { + return + } + linearRecur(n: n - 1) + } + ``` + +=== "JS" + + ```javascript title="space_complexity.js" + /* 선형 차수(재귀 구현) */ + function linearRecur(n) { + console.log(`재귀 n = ${n}`); + if (n === 1) return; + linearRecur(n - 1); + } + ``` + +=== "TS" + + ```typescript title="space_complexity.ts" + /* 선형 차수(재귀 구현) */ + function linearRecur(n: number): void { + console.log(`재귀 n = ${n}`); + if (n === 1) return; + linearRecur(n - 1); + } + ``` + +=== "Dart" + + ```dart title="space_complexity.dart" + /* 선형 차수(재귀 구현) */ + void linearRecur(int n) { + print('재귀 n = $n'); + if (n == 1) return; + linearRecur(n - 1); + } + ``` + +=== "Rust" + + ```rust title="space_complexity.rs" + /* 선형 차수(재귀 구현) */ + fn linear_recur(n: i32) { + println!("재귀 n = {}", n); + if n == 1 { + return; + }; + linear_recur(n - 1); + } + ``` + +=== "C" + + ```c title="space_complexity.c" + /* 선형 차수(재귀 구현) */ + void linearRecur(int n) { + printf("재귀 n = %d\r\n", n); + if (n == 1) + return; + linearRecur(n - 1); + } + ``` + +=== "Kotlin" + + ```kotlin title="space_complexity.kt" + /* 선형 차수(재귀 구현) */ + fun linearRecur(n: Int) { + println("재귀 n = $n") + if (n == 1) + return + linearRecur(n - 1) + } + ``` + +=== "Ruby" + + ```ruby title="space_complexity.rb" + ### 선형 차수(재귀 구현)### + def linear_recur(n) + puts "재귀 n = #{n}" + return if n == 1 + linear_recur(n - 1) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +![재귀 함수에서 발생하는 선형 공간 복잡도](space_complexity.assets/space_complexity_recursive_linear.png){ class="animation-figure" } + +

그림 2-17   재귀 함수에서 발생하는 선형 공간 복잡도

+ +### 3.   제곱 공간 $O(n^2)$ {data-toc-label="3.   제곱 공간"} + +제곱 공간은 원소 수가 $n$의 제곱에 비례하는 행렬과 그래프에 흔히 나타난다. + +=== "Python" + + ```python title="space_complexity.py" + def quadratic(n: int): + """제곱 차수""" + # 이차원 리스트는 O(n^2) 공간을 차지한다 + num_matrix = [[0] * n for _ in range(n)] + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 제곱 차수 */ + void quadratic(int n) { + // 이차원 리스트는 O(n^2) 공간을 차지한다 + vector> numMatrix; + for (int i = 0; i < n; i++) { + vector tmp; + for (int j = 0; j < n; j++) { + tmp.push_back(0); + } + numMatrix.push_back(tmp); + } + } + ``` + +=== "Java" + + ```java title="space_complexity.java" + /* 제곱 차수 */ + void quadratic(int n) { + // 행렬은 O(n^2) 공간을 차지한다 + int[][] numMatrix = new int[n][n]; + // 이차원 리스트는 O(n^2) 공간을 차지한다 + List> numList = new ArrayList<>(); + for (int i = 0; i < n; i++) { + List tmp = new ArrayList<>(); + for (int j = 0; j < n; j++) { + tmp.add(0); + } + numList.add(tmp); + } + } + ``` + +=== "C#" + + ```csharp title="space_complexity.cs" + /* 제곱 차수 */ + void Quadratic(int n) { + // 행렬은 O(n^2) 공간을 차지한다 + int[,] numMatrix = new int[n, n]; + // 이차원 리스트는 O(n^2) 공간을 차지한다 + List> numList = []; + for (int i = 0; i < n; i++) { + List tmp = []; + for (int j = 0; j < n; j++) { + tmp.Add(0); + } + numList.Add(tmp); + } + } + ``` + +=== "Go" + + ```go title="space_complexity.go" + /* 제곱 차수 */ + func spaceQuadratic(n int) { + // 행렬은 O(n^2) 공간을 차지한다 + numMatrix := make([][]int, n) + for i := 0; i < n; i++ { + numMatrix[i] = make([]int, n) + } + } + ``` + +=== "Swift" + + ```swift title="space_complexity.swift" + /* 제곱 차수 */ + func quadratic(n: Int) { + // 이차원 리스트는 O(n^2) 공간을 차지한다 + let numList = Array(repeating: Array(repeating: 0, count: n), count: n) + } + ``` + +=== "JS" + + ```javascript title="space_complexity.js" + /* 제곱 차수 */ + function quadratic(n) { + // 행렬은 O(n^2) 공간을 차지한다 + const numMatrix = Array(n) + .fill(null) + .map(() => Array(n).fill(null)); + // 이차원 리스트는 O(n^2) 공간을 차지한다 + const numList = []; + for (let i = 0; i < n; i++) { + const tmp = []; + for (let j = 0; j < n; j++) { + tmp.push(0); + } + numList.push(tmp); + } + } + ``` + +=== "TS" + + ```typescript title="space_complexity.ts" + /* 제곱 차수 */ + function quadratic(n: number): void { + // 행렬은 O(n^2) 공간을 차지한다 + const numMatrix = Array(n) + .fill(null) + .map(() => Array(n).fill(null)); + // 이차원 리스트는 O(n^2) 공간을 차지한다 + const numList = []; + for (let i = 0; i < n; i++) { + const tmp = []; + for (let j = 0; j < n; j++) { + tmp.push(0); + } + numList.push(tmp); + } + } + ``` + +=== "Dart" + + ```dart title="space_complexity.dart" + /* 제곱 차수 */ + void quadratic(int n) { + // 행렬은 O(n^2) 공간을 차지한다 + List> numMatrix = List.generate(n, (_) => List.filled(n, 0)); + // 이차원 리스트는 O(n^2) 공간을 차지한다 + List> numList = []; + for (var i = 0; i < n; i++) { + List tmp = []; + for (int j = 0; j < n; j++) { + tmp.add(0); + } + numList.add(tmp); + } + } + ``` + +=== "Rust" + + ```rust title="space_complexity.rs" + /* 제곱 차수 */ + #[allow(unused)] + fn quadratic(n: i32) { + // 행렬은 O(n^2) 공간을 차지한다 + let num_matrix = vec![vec![0; n as usize]; n as usize]; + // 이차원 리스트는 O(n^2) 공간을 차지한다 + let mut num_list = Vec::new(); + for i in 0..n { + let mut tmp = Vec::new(); + for j in 0..n { + tmp.push(0); + } + num_list.push(tmp); + } + } + ``` + +=== "C" + + ```c title="space_complexity.c" + /* 제곱 차수 */ + void quadratic(int n) { + // 이차원 리스트는 O(n^2) 공간을 차지한다 + int **numMatrix = malloc(sizeof(int *) * n); + for (int i = 0; i < n; i++) { + int *tmp = malloc(sizeof(int) * n); + for (int j = 0; j < n; j++) { + tmp[j] = 0; + } + numMatrix[i] = tmp; + } + + // 메모리 해제 + for (int i = 0; i < n; i++) { + free(numMatrix[i]); + } + free(numMatrix); + } + ``` + +=== "Kotlin" + + ```kotlin title="space_complexity.kt" + /* 제곱 차수 */ + fun quadratic(n: Int) { + // 행렬은 O(n^2) 공간을 차지한다 + val numMatrix = arrayOfNulls?>(n) + // 이차원 리스트는 O(n^2) 공간을 차지한다 + val numList = mutableListOf>() + for (i in 0..() + for (j in 0.. + + +아래 그림(2-18)과 같이 이 함수의 재귀 깊이는 $n$이고, 각 재귀 함수에서 길이가 각각 $n$, $n-1$, $\dots$, $2$, $1$인 배열을 초기화한다. 평균 길이는 $n / 2$이므로 전체적으로 $O(n^2)$ 공간을 차지한다. + +=== "Python" + + ```python title="space_complexity.py" + def quadratic_recur(n: int) -> int: + """제곱 차수(재귀 구현)""" + if n <= 0: + return 0 + # 배열 nums의 길이는 n, n-1, ..., 2, 1이다 + nums = [0] * n + return quadratic_recur(n - 1) + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 제곱 차수(재귀 구현) */ + int quadraticRecur(int n) { + if (n <= 0) + return 0; + vector nums(n); + cout << "재귀 n = " << n << " 일 때 nums의 길이 = " << nums.size() << endl; + return quadraticRecur(n - 1); + } + ``` + +=== "Java" + + ```java title="space_complexity.java" + /* 제곱 차수(재귀 구현) */ + int quadraticRecur(int n) { + if (n <= 0) + return 0; + // 배열 nums의 길이는 n, n-1, ..., 2, 1이다 + int[] nums = new int[n]; + System.out.println("재귀 n = " + n + " 일 때 nums의 길이 = " + nums.length); + return quadraticRecur(n - 1); + } + ``` + +=== "C#" + + ```csharp title="space_complexity.cs" + /* 제곱 차수(재귀 구현) */ + int QuadraticRecur(int n) { + if (n <= 0) return 0; + int[] nums = new int[n]; + Console.WriteLine("재귀 n = " + n + " 일 때 nums의 길이 = " + nums.Length); + return QuadraticRecur(n - 1); + } + ``` + +=== "Go" + + ```go title="space_complexity.go" + /* 제곱 차수(재귀 구현) */ + func spaceQuadraticRecur(n int) int { + if n <= 0 { + return 0 + } + nums := make([]int, n) + fmt.Printf("재귀 n = %d에서 nums의 길이 = %d \n", n, len(nums)) + return spaceQuadraticRecur(n - 1) + } + ``` + +=== "Swift" + + ```swift title="space_complexity.swift" + /* 제곱 차수(재귀 구현) */ + @discardableResult + func quadraticRecur(n: Int) -> Int { + if n <= 0 { + return 0 + } + // 배열 nums의 길이는 n, n-1, ..., 2, 1이다 + let nums = Array(repeating: 0, count: n) + print("재귀(n = \(n))의 nums 길이 = \(nums.count)") + return quadraticRecur(n: n - 1) + } + ``` + +=== "JS" + + ```javascript title="space_complexity.js" + /* 제곱 차수(재귀 구현) */ + function quadraticRecur(n) { + if (n <= 0) return 0; + const nums = new Array(n); + console.log(`재귀 n = ${n}일 때 nums의 길이 = ${nums.length}`); + return quadraticRecur(n - 1); + } + ``` + +=== "TS" + + ```typescript title="space_complexity.ts" + /* 제곱 차수(재귀 구현) */ + function quadraticRecur(n: number): number { + if (n <= 0) return 0; + const nums = new Array(n); + console.log(`재귀 n = ${n}일 때 nums의 길이 = ${nums.length}`); + return quadraticRecur(n - 1); + } + ``` + +=== "Dart" + + ```dart title="space_complexity.dart" + /* 제곱 차수(재귀 구현) */ + int quadraticRecur(int n) { + if (n <= 0) return 0; + List nums = List.filled(n, 0); + print('재귀(n = $n)의 nums 길이 = ${nums.length}'); + return quadraticRecur(n - 1); + } + ``` + +=== "Rust" + + ```rust title="space_complexity.rs" + /* 제곱 차수(재귀 구현) */ + fn quadratic_recur(n: i32) -> i32 { + if n <= 0 { + return 0; + }; + // 배열 nums의 길이는 n, n-1, ..., 2, 1이다 + let nums = vec![0; n as usize]; + println!("재귀 n = {}에서 nums의 길이 = {}", n, nums.len()); + return quadratic_recur(n - 1); + } + ``` + +=== "C" + + ```c title="space_complexity.c" + /* 제곱 차수(재귀 구현) */ + int quadraticRecur(int n) { + if (n <= 0) + return 0; + int *nums = malloc(sizeof(int) * n); + printf("재귀 n = %d에서 nums의 길이 = %d\r\n", n, n); + int res = quadraticRecur(n - 1); + free(nums); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="space_complexity.kt" + /* 제곱 차수(재귀 구현) */ + tailrec fun quadraticRecur(n: Int): Int { + if (n <= 0) + return 0 + // 배열 nums의 길이는 n, n-1, ..., 2, 1이다 + val nums = Array(n) { 0 } + println("재귀(n = $n)의 nums 길이 = ${nums.size}") + return quadraticRecur(n - 1) + } + ``` + +=== "Ruby" + + ```ruby title="space_complexity.rb" + ### 제곱 차수(재귀 구현)### + def quadratic_recur(n) + return 0 unless n > 0 + + # 배열 nums의 길이는 n, n-1, ..., 2, 1이다 + nums = Array.new(n, 0) + quadratic_recur(n - 1) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +![재귀 함수에서 발생하는 제곱 공간 복잡도](space_complexity.assets/space_complexity_recursive_quadratic.png){ class="animation-figure" } + +

그림 2-18   재귀 함수에서 발생하는 제곱 공간 복잡도

+ +### 4.   지수 공간 $O(2^n)$ {data-toc-label="4.   지수 공간"} + +지수 공간은 이진 트리에 흔히 나타난다. 아래 그림(2-19)에서 층수가 $n$인 ‘포화 이진 트리’의 노드 수는 $2^n - 1$이므로 $O(2^n)$ 공간을 차지한다. + +=== "Python" + + ```python title="space_complexity.py" + def build_tree(n: int) -> TreeNode | None: + """지수 차수(포화 이진 트리 구성)""" + if n == 0: + return None + root = TreeNode(0) + root.left = build_tree(n - 1) + root.right = build_tree(n - 1) + return root + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 지수 차수(포화 이진 트리 구성) */ + TreeNode *buildTree(int n) { + if (n == 0) + return nullptr; + TreeNode *root = new TreeNode(0); + root->left = buildTree(n - 1); + root->right = buildTree(n - 1); + return root; + } + ``` + +=== "Java" + + ```java title="space_complexity.java" + /* 지수 차수(포화 이진 트리 구성) */ + TreeNode buildTree(int n) { + if (n == 0) + return null; + TreeNode root = new TreeNode(0); + root.left = buildTree(n - 1); + root.right = buildTree(n - 1); + return root; + } + ``` + +=== "C#" + + ```csharp title="space_complexity.cs" + /* 지수 차수(포화 이진 트리 구성) */ + TreeNode? BuildTree(int n) { + if (n == 0) return null; + TreeNode root = new(0) { + left = BuildTree(n - 1), + right = BuildTree(n - 1) + }; + return root; + } + ``` + +=== "Go" + + ```go title="space_complexity.go" + /* 지수 차수(포화 이진 트리 구성) */ + func buildTree(n int) *TreeNode { + if n == 0 { + return nil + } + root := NewTreeNode(0) + root.Left = buildTree(n - 1) + root.Right = buildTree(n - 1) + return root + } + ``` + +=== "Swift" + + ```swift title="space_complexity.swift" + /* 지수 차수(포화 이진 트리 구성) */ + func buildTree(n: Int) -> TreeNode? { + if n == 0 { + return nil + } + let root = TreeNode(x: 0) + root.left = buildTree(n: n - 1) + root.right = buildTree(n: n - 1) + return root + } + ``` + +=== "JS" + + ```javascript title="space_complexity.js" + /* 지수 차수(포화 이진 트리 구성) */ + function buildTree(n) { + if (n === 0) return null; + const root = new TreeNode(0); + root.left = buildTree(n - 1); + root.right = buildTree(n - 1); + return root; + } + ``` + +=== "TS" + + ```typescript title="space_complexity.ts" + /* 지수 차수(포화 이진 트리 구성) */ + function buildTree(n: number): TreeNode | null { + if (n === 0) return null; + const root = new TreeNode(0); + root.left = buildTree(n - 1); + root.right = buildTree(n - 1); + return root; + } + ``` + +=== "Dart" + + ```dart title="space_complexity.dart" + /* 지수 차수(포화 이진 트리 구성) */ + TreeNode? buildTree(int n) { + if (n == 0) return null; + TreeNode root = TreeNode(0); + root.left = buildTree(n - 1); + root.right = buildTree(n - 1); + return root; + } + ``` + +=== "Rust" + + ```rust title="space_complexity.rs" + /* 지수 차수(포화 이진 트리 구성) */ + fn build_tree(n: i32) -> Option>> { + if n == 0 { + return None; + }; + let root = TreeNode::new(0); + root.borrow_mut().left = build_tree(n - 1); + root.borrow_mut().right = build_tree(n - 1); + return Some(root); + } + ``` + +=== "C" + + ```c title="space_complexity.c" + /* 지수 차수(포화 이진 트리 구성) */ + TreeNode *buildTree(int n) { + if (n == 0) + return NULL; + TreeNode *root = newTreeNode(0); + root->left = buildTree(n - 1); + root->right = buildTree(n - 1); + return root; + } + ``` + +=== "Kotlin" + + ```kotlin title="space_complexity.kt" + /* 지수 차수(포화 이진 트리 구성) */ + fun buildTree(n: Int): TreeNode? { + if (n == 0) + return null + val root = TreeNode(0) + root.left = buildTree(n - 1) + root.right = buildTree(n - 1) + return root + } + ``` + +=== "Ruby" + + ```ruby title="space_complexity.rb" + ### 지수 차수(포화 이진 트리 구성)### + def build_tree(n) + return if n == 0 + + TreeNode.new.tap do |root| + root.left = build_tree(n - 1) + root.right = build_tree(n - 1) + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +![포화 이진 트리에서 발생하는 지수 공간 복잡도](space_complexity.assets/space_complexity_exponential.png){ class="animation-figure" } + +

그림 2-19   포화 이진 트리에서 발생하는 지수 공간 복잡도

+ +### 5.   로그 공간 $O(\log n)$ {data-toc-label="5.   로그 공간"} + +로그 공간은 분할 정복 알고리즘에 흔히 나타난다. 예를 들어 병합 정렬은 길이가 $n$인 배열을 입력받아 재귀 호출마다 중간 지점에서 반으로 나눈다. 높이가 $\log n$인 재귀 트리가 만들어지므로 $O(\log n)$의 스택 프레임 공간을 사용한다. + +또 다른 예로 수를 문자열로 변환하는 경우를 보자. 양의 정수 $n$을 입력받으면 자릿수는 $\lfloor \log_{10} n \rfloor + 1$이며, 대응하는 문자열 길이도 $\lfloor \log_{10} n \rfloor + 1$이다. 따라서 공간 복잡도는 $O(\log_{10} n + 1) = O(\log n)$이다. + +## 2.4.4   시간과 공간의 절충 + +이상적으로는 알고리즘의 시간 복잡도와 공간 복잡도가 모두 최적이기를 바란다. 그러나 실제로 두 복잡도를 동시에 최적화하기는 대개 매우 어렵다. + +**시간 복잡도를 낮추려면 보통 공간 복잡도가 높아지는 대가를 치러야 하며, 그 반대도 마찬가지다**. 메모리 공간을 더 사용하여 실행 속도를 높이는 방식을 ‘공간으로 시간을 절약하기’, 반대의 방식을 ‘시간으로 공간을 절약하기’라고 한다. + +어떤 방식을 선택할지는 어느 쪽을 더 중시하는지에 달려 있다. 대부분의 경우 시간이 공간보다 귀하므로 ‘공간으로 시간을 절약하기’가 더 널리 쓰인다. 물론 데이터 양이 매우 많다면 공간 복잡도를 관리하는 것 역시 중요하다. diff --git a/ko/docs/chapter_computational_complexity/summary.md b/ko/docs/chapter_computational_complexity/summary.md new file mode 100644 index 000000000..120959c59 --- /dev/null +++ b/ko/docs/chapter_computational_complexity/summary.md @@ -0,0 +1,59 @@ +--- +comments: true +--- + +# 2.5   요약 + +### 1.   핵심 정리 + +**알고리즘 효율 평가** + +- 시간 효율과 공간 효율은 알고리즘의 우수성을 평가하는 두 가지 주요 지표다. +- 실제 측정으로 알고리즘 효율을 평가할 수 있지만, 측정 환경의 영향을 없애기 어렵고 많은 컴퓨팅 자원이 든다. +- 복잡도 분석은 실제 측정의 단점을 극복한다. 분석 결과는 모든 실행 플랫폼에 적용할 수 있으며, 데이터 크기에 따른 알고리즘 효율을 보여 준다. + +**시간 복잡도** + +- 시간 복잡도는 데이터 양이 증가할 때 알고리즘 실행 시간이 증가하는 추세를 측정하여 효율을 효과적으로 평가한다. 다만 입력 데이터가 적거나 시간 복잡도가 같은 경우처럼, 알고리즘 효율의 우열을 정확히 비교할 수 없는 상황도 있다. +- 최악 시간 복잡도는 빅 $O$ 표기법으로 나타내며 함수의 점근적 상한에 해당한다. $n$이 양의 무한대로 향할 때 연산 횟수 $T(n)$의 증가 차수를 반영한다. +- 시간 복잡도를 구하는 과정은 두 단계다. 먼저 연산 횟수를 세고, 그다음 점근적 상한을 판단한다. +- 대표적인 시간 복잡도를 낮은 순서대로 나열하면 $O(1)$, $O(\log n)$, $O(n)$, $O(n \log n)$, $O(n^2)$, $O(2^n)$, $O(n!)$ 등이 있다. +- 일부 알고리즘의 시간 복잡도는 고정되어 있지 않고 입력 데이터의 분포에 따라 달라진다. 시간 복잡도는 최악, 최선, 평균 시간 복잡도로 나뉜다. 최선의 경우에 도달하려면 대개 입력 데이터가 엄격한 조건을 만족해야 하므로 최선 시간 복잡도는 거의 쓰지 않는다. +- 평균 시간 복잡도는 무작위 입력 데이터에서의 실행 효율을 반영하므로 실제 응용에서의 알고리즘 성능에 가장 가깝다. 이를 계산하려면 입력 데이터의 분포와 이를 종합한 기댓값을 구해야 한다. + +**공간 복잡도** + +- 공간 복잡도는 시간 복잡도와 비슷하게, 데이터 양이 증가할 때 알고리즘이 차지하는 메모리 공간이 증가하는 추세를 측정한다. +- 알고리즘 실행에 쓰이는 메모리 공간은 입력 공간, 임시 공간, 출력 공간으로 나뉜다. 보통 입력 공간은 공간 복잡도 계산에 포함하지 않는다. 임시 공간은 임시 데이터, 스택 프레임 공간, 명령어 공간으로 나뉘며, 스택 프레임 공간은 보통 재귀 함수에서만 공간 복잡도에 영향을 준다. +- 보통 최악 공간 복잡도에만 관심을 둔다. 즉, 가장 불리한 입력 데이터와 실행 시점을 기준으로 공간 복잡도를 계산한다. +- 대표적인 공간 복잡도를 낮은 순서대로 나열하면 $O(1)$, $O(\log n)$, $O(n)$, $O(n^2)$, $O(2^n)$ 등이 있다. + +### 2.   Q & A + +**Q**: 꼬리 재귀의 공간 복잡도는 $O(1)$인가? + +이론적으로 꼬리 재귀 함수의 공간 복잡도는 $O(1)$로 최적화할 수 있다. 그러나 Java, Python, C++, Go, C# 등 대다수 프로그래밍 언어는 자동 꼬리 재귀 최적화를 지원하지 않으므로, 보통 공간 복잡도를 $O(n)$으로 본다. + +**Q**: 함수와 메서드라는 두 용어는 어떻게 다른가? + +함수(function)는 독립적으로 실행할 수 있으며, 모든 인수가 명시적으로 전달된다. 메서드(method)는 객체와 연결되어 있고, 자신을 호출하는 객체에 암묵적으로 전달되며, 클래스 인스턴스에 포함된 데이터를 조작할 수 있다. + +몇 가지 흔히 쓰는 프로그래밍 언어를 예로 살펴보자. + +- C는 절차적 프로그래밍 언어로 객체 지향 개념이 없으므로 함수만 있다. 다만 구조체(struct)를 만들어 객체 지향 프로그래밍을 흉내 낼 수 있으며, 구조체에 연결된 함수는 다른 언어의 메서드에 해당한다. +- Java와 C#은 객체 지향 프로그래밍 언어로, 코드 블록(메서드)은 보통 클래스의 일부다. 정적 메서드는 클래스에 묶여 있고 특정 인스턴스 변수에 접근할 수 없으므로 함수와 비슷하게 동작한다. +- C++와 Python은 절차적 프로그래밍(함수)과 객체 지향 프로그래밍(메서드)을 모두 지원한다. + +**Q**: ‘대표적인 공간 복잡도 유형’ 그림은 사용 공간의 절대적인 크기를 나타내는가? + +아니다. 이 그림은 공간 복잡도를 보여 주므로, 사용 공간의 절대적인 크기가 아니라 증가 추세를 나타낸다. + +$n = 8$이라고 놓으면 각 곡선의 값이 해당 함수와 일치하지 않을 수 있다. 이는 각 곡선에 상수항을 넣어 값의 범위를 보기 편한 크기로 줄였기 때문이다. + +실제로는 각 방법의 ‘상수항’에 해당하는 비용을 보통 알 수 없으므로, $n = 8$일 때는 복잡도만으로 최적의 해법을 고르기 어렵다. 반면 $n = 8^5$일 때는 증가 추세가 지배적인 영향을 주므로 훨씬 쉽게 선택할 수 있다. + +**Q** 실제 사용 상황에 따라 시간이나 공간을 희생하도록 알고리즘을 설계하기도 하는가? + +실제 응용에서는 대부분 공간을 더 사용하여 시간을 줄이는 쪽을 선택한다. 예를 들어 데이터베이스 인덱스에서는 보통 B+ 트리나 해시 인덱스를 구축한다. 메모리 공간을 많이 사용하는 대신 $O(\log n)$, 나아가 $O(1)$의 효율적인 조회가 가능하다. + +공간 자원이 귀한 상황에서는 시간을 더 들여 공간을 절약하기도 한다. 예를 들어 임베디드 개발에서는 장치 메모리가 귀하므로, 해시 테이블 대신 배열에서 순차 탐색을 수행할 수 있다. 메모리 사용량을 줄이는 대가로 탐색 속도가 느려진다. diff --git a/ko/docs/chapter_computational_complexity/time_complexity.md b/ko/docs/chapter_computational_complexity/time_complexity.md new file mode 100644 index 000000000..8c23be44e --- /dev/null +++ b/ko/docs/chapter_computational_complexity/time_complexity.md @@ -0,0 +1,3675 @@ +--- +comments: true +--- + +# 2.3   시간 복잡도 + +실행 시간은 알고리즘 효율을 직관적이고 정확하게 보여 준다. 코드의 실행 시간을 정확히 추정하려면 어떻게 해야 할까? + +1. **실행 플랫폼을 정한다**. 하드웨어 구성, 프로그래밍 언어, 시스템 환경 등이 포함되며, 모두 코드의 실행 효율에 영향을 준다. +2. **각 연산에 필요한 실행 시간을 평가한다**. 예를 들어 덧셈 연산 `+`는 1 ns, 곱셈 연산 `*`는 10 ns, 출력 연산 `print()`는 5 ns가 걸린다고 하자. +3. **코드에 있는 모든 연산을 센다**. 그런 다음 각 연산의 실행 시간을 모두 더해 전체 실행 시간을 구한다. + +예를 들어 다음 코드에서 입력 데이터 크기는 $n$이다. + +=== "Python" + + ```python title="" + # 어떤 실행 플랫폼에서 + def algorithm(n: int): + a = 2 # 1 ns + a = a + 1 # 1 ns + a = a * 2 # 10 ns + # n번 반복 + for _ in range(n): # 1 ns + print(0) # 5 ns + ``` + +=== "C++" + + ```cpp title="" + // 어떤 실행 플랫폼에서 + void algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n번 반복 + for (int i = 0; i < n; i++) { // 1 ns + cout << 0 << endl; // 5 ns + } + } + ``` + +=== "Java" + + ```java title="" + // 어떤 실행 플랫폼에서 + void algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n번 반복 + for (int i = 0; i < n; i++) { // 1 ns + System.out.println(0); // 5 ns + } + } + ``` + +=== "C#" + + ```csharp title="" + // 어떤 실행 플랫폼에서 + void Algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n번 반복 + for (int i = 0; i < n; i++) { // 1 ns + Console.WriteLine(0); // 5 ns + } + } + ``` + +=== "Go" + + ```go title="" + // 어떤 실행 플랫폼에서 + func algorithm(n int) { + a := 2 // 1 ns + a = a + 1 // 1 ns + a = a * 2 // 10 ns + // n번 반복 + for i := 0; i < n; i++ { // 1 ns + fmt.Println(a) // 5 ns + } + } + ``` + +=== "Swift" + + ```swift title="" + // 어떤 실행 플랫폼에서 + func algorithm(n: Int) { + var a = 2 // 1 ns + a = a + 1 // 1 ns + a = a * 2 // 10 ns + // n번 반복 + for _ in 0 ..< n { // 1 ns + print(0) // 5 ns + } + } + ``` + +=== "JS" + + ```javascript title="" + // 어떤 실행 플랫폼에서 + function algorithm(n) { + var a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n번 반복 + for(let i = 0; i < n; i++) { // 1 ns + console.log(0); // 5 ns + } + } + ``` + +=== "TS" + + ```typescript title="" + // 어떤 실행 플랫폼에서 + function algorithm(n: number): void { + var a: number = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n번 반복 + for(let i = 0; i < n; i++) { // 1 ns + console.log(0); // 5 ns + } + } + ``` + +=== "Dart" + + ```dart title="" + // 어떤 실행 플랫폼에서 + void algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n번 반복 + for (int i = 0; i < n; i++) { // 1 ns + print(0); // 5 ns + } + } + ``` + +=== "Rust" + + ```rust title="" + // 어떤 실행 플랫폼에서 + fn algorithm(n: i32) { + let mut a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n번 반복 + for _ in 0..n { // 1 ns + println!("{}", 0); // 5 ns + } + } + ``` + +=== "C" + + ```c title="" + // 어떤 실행 플랫폼에서 + void algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n번 반복 + for (int i = 0; i < n; i++) { // 1 ns + printf("%d", 0); // 5 ns + } + } + ``` + +=== "Kotlin" + + ```kotlin title="" + // 어떤 실행 플랫폼에서 + fun algorithm(n: Int) { + var a = 2 // 1 ns + a = a + 1 // 1 ns + a = a * 2 // 10 ns + // n번 반복 + for (i in 0.. 그림 2-7   알고리즘 A, B, C의 시간 증가 추세

+ +알고리즘의 실행 시간을 직접 계산하는 방식과 비교하면 시간 복잡도 분석에는 어떤 특징이 있을까? + +- **시간 복잡도는 알고리즘 효율을 효과적으로 평가할 수 있다**. 예를 들어 알고리즘 `B`의 실행 시간은 선형으로 증가하므로 $n > 1$이면 알고리즘 `A`보다 느리고, $n > 1000000$이면 알고리즘 `C`보다 느리다. 실제로 입력 데이터 크기 $n$이 충분히 크면 ‘상수 시간’ 알고리즘은 반드시 ‘선형 시간’ 알고리즘보다 유리하다. 이것이 바로 시간의 증가 추세가 뜻하는 바다. +- **시간 복잡도를 구하는 방법은 더 간단하다**. 실행 플랫폼과 연산 종류는 알고리즘 실행 시간의 증가 추세와 무관하다. 따라서 시간 복잡도 분석에서는 모든 연산의 실행 시간을 같은 ‘단위 시간’으로 볼 수 있다. 그러면 ‘연산 실행 시간 계산’을 ‘연산 횟수 계산’으로 단순화할 수 있어 추정이 훨씬 쉬워진다. +- **시간 복잡도에도 한계는 있다**. 예를 들어 알고리즘 `A`와 `C`의 시간 복잡도는 같지만 실제 실행 시간에는 큰 차이가 있다. 마찬가지로 알고리즘 `B`의 시간 복잡도는 `C`보다 높지만, 입력 데이터 크기 $n$이 작으면 `B`가 `C`보다 확실히 유리하다. 이런 경우에는 시간 복잡도만으로 효율의 우열을 판단하기 어렵다. 물론 이러한 문제에도 불구하고 복잡도 분석은 여전히 알고리즘 효율을 평가하는 가장 효과적이고 널리 쓰이는 방법이다. + +## 2.3.2   함수의 점근적 상한 + +입력 크기가 $n$인 다음 함수를 살펴보자. + +=== "Python" + + ```python title="" + def algorithm(n: int): + a = 1 # +1 + a = a + 1 # +1 + a = a * 2 # +1 + # n번 반복 + for i in range(n): # +1 + print(0) # +1 + ``` + +=== "C++" + + ```cpp title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n번 반복 + for (int i = 0; i < n; i++) { // +1(매번 i ++ 실행) + cout << 0 << endl; // +1 + } + } + ``` + +=== "Java" + + ```java title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n번 반복 + for (int i = 0; i < n; i++) { // +1(매번 i ++ 실행) + System.out.println(0); // +1 + } + } + ``` + +=== "C#" + + ```csharp title="" + void Algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n번 반복 + for (int i = 0; i < n; i++) { // +1(매번 i ++ 실행) + Console.WriteLine(0); // +1 + } + } + ``` + +=== "Go" + + ```go title="" + func algorithm(n int) { + a := 1 // +1 + a = a + 1 // +1 + a = a * 2 // +1 + // n번 반복 + for i := 0; i < n; i++ { // +1 + fmt.Println(a) // +1 + } + } + ``` + +=== "Swift" + + ```swift title="" + func algorithm(n: Int) { + var a = 1 // +1 + a = a + 1 // +1 + a = a * 2 // +1 + // n번 반복 + for _ in 0 ..< n { // +1 + print(0) // +1 + } + } + ``` + +=== "JS" + + ```javascript title="" + function algorithm(n) { + var a = 1; // +1 + a += 1; // +1 + a *= 2; // +1 + // n번 반복 + for(let i = 0; i < n; i++){ // +1(매번 i ++ 실행) + console.log(0); // +1 + } + } + ``` + +=== "TS" + + ```typescript title="" + function algorithm(n: number): void{ + var a: number = 1; // +1 + a += 1; // +1 + a *= 2; // +1 + // n번 반복 + for(let i = 0; i < n; i++){ // +1(매번 i ++ 실행) + console.log(0); // +1 + } + } + ``` + +=== "Dart" + + ```dart title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n번 반복 + for (int i = 0; i < n; i++) { // +1(매번 i ++ 실행) + print(0); // +1 + } + } + ``` + +=== "Rust" + + ```rust title="" + fn algorithm(n: i32) { + let mut a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + + // n번 반복 + for _ in 0..n { // +1(매번 i ++ 실행) + println!("{}", 0); // +1 + } + } + ``` + +=== "C" + + ```c title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n번 반복 + for (int i = 0; i < n; i++) { // +1(매번 i ++ 실행) + printf("%d", 0); // +1 + } + } + ``` + +=== "Kotlin" + + ```kotlin title="" + fun algorithm(n: Int) { + var a = 1 // +1 + a = a + 1 // +1 + a = a * 2 // +1 + // n번 반복 + for (i in 0..빅 $O$ 표기법(big-$O$ notation)이라고 하며, 함수 $T(n)$의 점근적 상한(asymptotic upper bound)을 나타낸다. + +시간 복잡도 분석은 본질적으로 ‘연산 횟수 $T(n)$’의 점근적 상한을 계산하는 것으로, 명확한 수학적 정의가 있다. + +!!! note "함수의 점근적 상한" + + 양의 실수 $c$와 실수 $n_0$가 존재하여 모든 $n > n_0$에 대해 $T(n) \leq c \cdot f(n)$이면, $f(n)$은 $T(n)$의 점근적 상한을 제공한다고 하며 $T(n) = O(f(n))$으로 표기한다. + +아래 그림(2-8)과 같이 점근적 상한을 계산한다는 것은 함수 $f(n)$을 찾는 것이다. 이 함수는 $n$이 무한대로 향할 때 $T(n)$과 $f(n)$의 증가 차수가 같고 상수 계수 $c$만큼만 차이가 나도록 해야 한다. + +![함수의 점근적 상한](time_complexity.assets/asymptotic_upper_bound.png){ class="animation-figure" } + +

그림 2-8   함수의 점근적 상한

+ +## 2.3.3   계산 방법 + +점근적 상한은 수학적인 느낌이 강하므로, 완전히 이해되지 않아도 걱정할 필요 없다. 우선 계산 방법을 익힌 뒤 반복해서 연습하면 그 수학적 의미도 점차 이해할 수 있다. + +정의에 따르면 $f(n)$을 정하면 시간 복잡도 $O(f(n))$을 얻는다. 그렇다면 점근적 상한 $f(n)$은 어떻게 정할까? 크게 두 단계로 나뉜다. 먼저 연산 횟수를 세고, 그다음 점근적 상한을 판단한다. + +### 1.   1단계: 연산 횟수 세기 + +코드를 위에서 아래로 한 줄씩 살펴보며 계산하면 된다. 다만 앞서 본 $c \cdot f(n)$의 상수 계수 $c$는 어떤 크기든 가능하므로, **연산 횟수 $T(n)$의 각종 계수와 상수항은 무시해도 된다**. 이 원리를 바탕으로 계산을 단순화하는 다음 요령을 얻을 수 있다. + +1. **$T(n)$의 상수를 무시한다**. 상수는 $n$과 무관하므로 시간 복잡도에 영향을 주지 않는다. +2. **모든 계수를 생략한다**. 예를 들어 $2n$번, $5n + 1$번 등의 반복은 모두 $n$번으로 단순화할 수 있다. $n$ 앞의 계수는 시간 복잡도에 영향을 주지 않기 때문이다. +3. **중첩 반복문에는 곱셈을 사용한다**. 전체 연산 횟수는 바깥쪽 반복문과 안쪽 반복문의 연산 횟수의 곱이며, 각 반복문에는 여전히 `1.`과 `2.`의 요령을 따로 적용할 수 있다. + +다음 함수에 위 요령을 적용하여 연산 횟수를 세어 보자. + +=== "Python" + + ```python title="" + def algorithm(n: int): + a = 1 # +0(요령 1) + a = a + n # +0(요령 1) + # +n(요령 2) + for i in range(5 * n + 1): + print(0) + # +n*n(요령 3) + for i in range(2 * n): + for j in range(n + 1): + print(0) + ``` + +=== "C++" + + ```cpp title="" + void algorithm(int n) { + int a = 1; // +0(요령 1) + a = a + n; // +0(요령 1) + // +n(요령 2) + for (int i = 0; i < 5 * n + 1; i++) { + cout << 0 << endl; + } + // +n*n(요령 3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + cout << 0 << endl; + } + } + } + ``` + +=== "Java" + + ```java title="" + void algorithm(int n) { + int a = 1; // +0(요령 1) + a = a + n; // +0(요령 1) + // +n(요령 2) + for (int i = 0; i < 5 * n + 1; i++) { + System.out.println(0); + } + // +n*n(요령 3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + System.out.println(0); + } + } + } + ``` + +=== "C#" + + ```csharp title="" + void Algorithm(int n) { + int a = 1; // +0(요령 1) + a = a + n; // +0(요령 1) + // +n(요령 2) + for (int i = 0; i < 5 * n + 1; i++) { + Console.WriteLine(0); + } + // +n*n(요령 3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + Console.WriteLine(0); + } + } + } + ``` + +=== "Go" + + ```go title="" + func algorithm(n int) { + a := 1 // +0(요령 1) + a = a + n // +0(요령 1) + // +n(요령 2) + for i := 0; i < 5 * n + 1; i++ { + fmt.Println(0) + } + // +n*n(요령 3) + for i := 0; i < 2 * n; i++ { + for j := 0; j < n + 1; j++ { + fmt.Println(0) + } + } + } + ``` + +=== "Swift" + + ```swift title="" + func algorithm(n: Int) { + var a = 1 // +0(요령 1) + a = a + n // +0(요령 1) + // +n(요령 2) + for _ in 0 ..< (5 * n + 1) { + print(0) + } + // +n*n(요령 3) + for _ in 0 ..< (2 * n) { + for _ in 0 ..< (n + 1) { + print(0) + } + } + } + ``` + +=== "JS" + + ```javascript title="" + function algorithm(n) { + let a = 1; // +0(요령 1) + a = a + n; // +0(요령 1) + // +n(요령 2) + for (let i = 0; i < 5 * n + 1; i++) { + console.log(0); + } + // +n*n(요령 3) + for (let i = 0; i < 2 * n; i++) { + for (let j = 0; j < n + 1; j++) { + console.log(0); + } + } + } + ``` + +=== "TS" + + ```typescript title="" + function algorithm(n: number): void { + let a = 1; // +0(요령 1) + a = a + n; // +0(요령 1) + // +n(요령 2) + for (let i = 0; i < 5 * n + 1; i++) { + console.log(0); + } + // +n*n(요령 3) + for (let i = 0; i < 2 * n; i++) { + for (let j = 0; j < n + 1; j++) { + console.log(0); + } + } + } + ``` + +=== "Dart" + + ```dart title="" + void algorithm(int n) { + int a = 1; // +0(요령 1) + a = a + n; // +0(요령 1) + // +n(요령 2) + for (int i = 0; i < 5 * n + 1; i++) { + print(0); + } + // +n*n(요령 3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + print(0); + } + } + } + ``` + +=== "Rust" + + ```rust title="" + fn algorithm(n: i32) { + let mut a = 1; // +0(요령 1) + a = a + n; // +0(요령 1) + + // +n(요령 2) + for i in 0..(5 * n + 1) { + println!("{}", 0); + } + + // +n*n(요령 3) + for i in 0..(2 * n) { + for j in 0..(n + 1) { + println!("{}", 0); + } + } + } + ``` + +=== "C" + + ```c title="" + void algorithm(int n) { + int a = 1; // +0(요령 1) + a = a + n; // +0(요령 1) + // +n(요령 2) + for (int i = 0; i < 5 * n + 1; i++) { + printf("%d", 0); + } + // +n*n(요령 3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + printf("%d", 0); + } + } + } + ``` + +=== "Kotlin" + + ```kotlin title="" + fun algorithm(n: Int) { + var a = 1 // +0(요령 1) + a = a + n // +0(요령 1) + // +n(요령 2) + for (i in 0..<5 * n + 1) { + println(0) + } + // +n*n(요령 3) + for (i in 0..<2 * n) { + for (j in 0.. 표 2-2   연산 횟수에 따른 시간 복잡도

+ +
+ +| 연산 횟수 $T(n)$ | 시간 복잡도 $O(f(n))$ | +| ---------------------- | -------------------- | +| $100000$ | $O(1)$ | +| $3n + 2$ | $O(n)$ | +| $2n^2 + 3n + 2$ | $O(n^2)$ | +| $n^3 + 10000n^2$ | $O(n^3)$ | +| $2^n + 10000n^{10000}$ | $O(2^n)$ | + +
+ +## 2.3.4   대표적인 유형 + +입력 데이터 크기를 $n$이라고 할 때, 대표적인 시간 복잡도 유형은 아래 그림(2-9)과 같다(낮은 순서대로 나열). + +$$ +\begin{aligned} +& O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!) \newline +& \text{상수} < \text{로그} < \text{선형} < \text{선형 로그} < \text{제곱} < \text{지수} < \text{팩토리얼} +\end{aligned} +$$ + +![대표적인 시간 복잡도 유형](time_complexity.assets/time_complexity_common_types.png){ class="animation-figure" } + +

그림 2-9   대표적인 시간 복잡도 유형

+ +### 1.   상수 시간 $O(1)$ {data-toc-label="1.   상수 시간"} + +상수 시간의 연산 횟수는 입력 데이터 크기 $n$과 무관하며, $n$이 변해도 달라지지 않는다. + +다음 함수에서 연산 횟수 `size`는 매우 클 수 있지만 입력 데이터 크기 $n$과 무관하므로, 시간 복잡도는 여전히 $O(1)$이다. + +=== "Python" + + ```python title="time_complexity.py" + def constant(n: int) -> int: + """상수 차수""" + count = 0 + size = 100000 + for _ in range(size): + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 상수 차수 */ + int constant(int n) { + int count = 0; + int size = 100000; + for (int i = 0; i < size; i++) + count++; + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 상수 차수 */ + int constant(int n) { + int count = 0; + int size = 100000; + for (int i = 0; i < size; i++) + count++; + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 상수 차수 */ + int Constant(int n) { + int count = 0; + int size = 100000; + for (int i = 0; i < size; i++) + count++; + return count; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 상수 차수 */ + func constant(n int) int { + count := 0 + size := 100000 + for i := 0; i < size; i++ { + count++ + } + return count + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 상수 차수 */ + func constant(n: Int) -> Int { + var count = 0 + let size = 100_000 + for _ in 0 ..< size { + count += 1 + } + return count + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 상수 차수 */ + function constant(n) { + let count = 0; + const size = 100000; + for (let i = 0; i < size; i++) count++; + return count; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 상수 차수 */ + function constant(n: number): number { + let count = 0; + const size = 100000; + for (let i = 0; i < size; i++) count++; + return count; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 상수 차수 */ + int constant(int n) { + int count = 0; + int size = 100000; + for (var i = 0; i < size; i++) { + count++; + } + return count; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 상수 차수 */ + fn constant(n: i32) -> i32 { + _ = n; + let mut count = 0; + let size = 100_000; + for _ in 0..size { + count += 1; + } + count + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 상수 차수 */ + int constant(int n) { + int count = 0; + int size = 100000; + for (int i = 0; i < size; i++) { + count++; + } + return count; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 상수 차수 */ + fun constant(n: Int): Int { + var count = 0 + val size = 100000 + for (i in 0.. + + +### 2.   선형 시간 $O(n)$ {data-toc-label="2.   선형 시간"} + +선형 시간의 연산 횟수는 입력 데이터 크기 $n$에 대해 선형으로 증가한다. 선형 시간은 보통 단일 반복문에서 나타난다. + +=== "Python" + + ```python title="time_complexity.py" + def linear(n: int) -> int: + """선형 차수""" + count = 0 + for _ in range(n): + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 선형 차수 */ + int linear(int n) { + int count = 0; + for (int i = 0; i < n; i++) + count++; + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 선형 차수 */ + int linear(int n) { + int count = 0; + for (int i = 0; i < n; i++) + count++; + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 선형 차수 */ + int Linear(int n) { + int count = 0; + for (int i = 0; i < n; i++) + count++; + return count; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 선형 차수 */ + func linear(n int) int { + count := 0 + for i := 0; i < n; i++ { + count++ + } + return count + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 선형 차수 */ + func linear(n: Int) -> Int { + var count = 0 + for _ in 0 ..< n { + count += 1 + } + return count + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 선형 차수 */ + function linear(n) { + let count = 0; + for (let i = 0; i < n; i++) count++; + return count; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 선형 차수 */ + function linear(n: number): number { + let count = 0; + for (let i = 0; i < n; i++) count++; + return count; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 선형 차수 */ + int linear(int n) { + int count = 0; + for (var i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 선형 차수 */ + fn linear(n: i32) -> i32 { + let mut count = 0; + for _ in 0..n { + count += 1; + } + count + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 선형 차수 */ + int linear(int n) { + int count = 0; + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 선형 차수 */ + fun linear(n: Int): Int { + var count = 0 + for (i in 0.. + + +배열 순회와 연결 리스트 순회 등의 연산은 시간 복잡도가 모두 $O(n)$이다. 여기서 $n$은 배열이나 연결 리스트의 길이다. + +=== "Python" + + ```python title="time_complexity.py" + def array_traversal(nums: list[int]) -> int: + """선형 차수(배열 순회)""" + count = 0 + # 반복 횟수는 배열 길이에 비례한다 + for num in nums: + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 선형 차수(배열 순회) */ + int arrayTraversal(vector &nums) { + int count = 0; + // 반복 횟수는 배열 길이에 비례한다 + for (int num : nums) { + count++; + } + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 선형 차수(배열 순회) */ + int arrayTraversal(int[] nums) { + int count = 0; + // 반복 횟수는 배열 길이에 비례한다 + for (int num : nums) { + count++; + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 선형 차수(배열 순회) */ + int ArrayTraversal(int[] nums) { + int count = 0; + // 반복 횟수는 배열 길이에 비례한다 + foreach (int num in nums) { + count++; + } + return count; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 선형 차수(배열 순회) */ + func arrayTraversal(nums []int) int { + count := 0 + // 반복 횟수는 배열 길이에 비례한다 + for range nums { + count++ + } + return count + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 선형 차수(배열 순회) */ + func arrayTraversal(nums: [Int]) -> Int { + var count = 0 + // 반복 횟수는 배열 길이에 비례한다 + for _ in nums { + count += 1 + } + return count + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 선형 차수(배열 순회) */ + function arrayTraversal(nums) { + let count = 0; + // 반복 횟수는 배열 길이에 비례한다 + for (let i = 0; i < nums.length; i++) { + count++; + } + return count; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 선형 차수(배열 순회) */ + function arrayTraversal(nums: number[]): number { + let count = 0; + // 반복 횟수는 배열 길이에 비례한다 + for (let i = 0; i < nums.length; i++) { + count++; + } + return count; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 선형 차수(배열 순회) */ + int arrayTraversal(List nums) { + int count = 0; + // 반복 횟수는 배열 길이에 비례한다 + for (var _num in nums) { + count++; + } + return count; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 선형 차수(배열 순회) */ + fn array_traversal(nums: &[i32]) -> i32 { + let mut count = 0; + // 반복 횟수는 배열 길이에 비례한다 + for _ in nums { + count += 1; + } + count + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 선형 차수(배열 순회) */ + int arrayTraversal(int *nums, int n) { + int count = 0; + // 반복 횟수는 배열 길이에 비례한다 + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 선형 차수(배열 순회) */ + fun arrayTraversal(nums: IntArray): Int { + var count = 0 + // 반복 횟수는 배열 길이에 비례한다 + for (num in nums) { + count++ + } + return count + } + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + ### 선형 차수(배열 순회)### + def array_traversal(nums) + count = 0 + + # 반복 횟수는 배열 길이에 비례한다 + for num in nums + count += 1 + end + + count + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +**입력 데이터 크기 $n$은 입력 데이터의 종류에 따라 구체적으로 정해야 한다**는 점에 주의하자. 첫 번째 예에서는 변수 $n$이 입력 데이터 크기이고, 두 번째 예에서는 배열의 길이 $n$이 데이터 크기다. + +### 3.   제곱 시간 $O(n^2)$ {data-toc-label="3.   제곱 시간"} + +제곱 시간의 연산 횟수는 입력 데이터 크기 $n$의 제곱에 비례하여 증가한다. 보통 중첩 반복문에서 나타나며, 바깥쪽과 안쪽 반복문의 시간 복잡도가 각각 $O(n)$이므로 전체 시간 복잡도는 $O(n^2)$이다. + +=== "Python" + + ```python title="time_complexity.py" + def quadratic(n: int) -> int: + """제곱 차수""" + count = 0 + # 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for i in range(n): + for j in range(n): + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 제곱 차수 */ + int quadratic(int n) { + int count = 0; + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + count++; + } + } + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 제곱 차수 */ + int quadratic(int n) { + int count = 0; + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + count++; + } + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 제곱 차수 */ + int Quadratic(int n) { + int count = 0; + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + count++; + } + } + return count; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 제곱 차수 */ + func quadratic(n int) int { + count := 0 + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + count++ + } + } + return count + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 제곱 차수 */ + func quadratic(n: Int) -> Int { + var count = 0 + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for _ in 0 ..< n { + for _ in 0 ..< n { + count += 1 + } + } + return count + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 제곱 차수 */ + function quadratic(n) { + let count = 0; + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + count++; + } + } + return count; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 제곱 차수 */ + function quadratic(n: number): number { + let count = 0; + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + count++; + } + } + return count; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 제곱 차수 */ + int quadratic(int n) { + int count = 0; + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + count++; + } + } + return count; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 제곱 차수 */ + fn quadratic(n: i32) -> i32 { + let mut count = 0; + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for _ in 0..n { + for _ in 0..n { + count += 1; + } + } + count + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 제곱 차수 */ + int quadratic(int n) { + int count = 0; + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + count++; + } + } + return count; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 제곱 차수 */ + fun quadratic(n: Int): Int { + var count = 0 + // 반복 횟수는 데이터 크기 n의 제곱에 비례한다 + for (i in 0.. + + +아래 그림(2-10)은 상수 시간, 선형 시간, 제곱 시간의 세 가지 시간 복잡도를 비교한다. + +![상수 시간, 선형 시간, 제곱 시간의 시간 복잡도](time_complexity.assets/time_complexity_constant_linear_quadratic.png){ class="animation-figure" } + +

그림 2-10   상수 시간, 선형 시간, 제곱 시간의 시간 복잡도

+ +버블 정렬을 예로 들면, 바깥쪽 반복문은 $n - 1$번 실행되고 안쪽 반복문은 $n-1$, $n-2$, $\dots$, $2$, $1$번, 평균 $n / 2$번 실행된다. 따라서 시간 복잡도는 $O((n - 1) n / 2) = O(n^2)$이다. + +=== "Python" + + ```python title="time_complexity.py" + def bubble_sort(nums: list[int]) -> int: + """제곱 차수(버블 정렬)""" + count = 0 # 카운터 + # 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in range(len(nums) - 1, 0, -1): + # 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in range(i): + if nums[j] > nums[j + 1]: + # nums[j]와 nums[j + 1]을 교환한다 + tmp: int = nums[j] + nums[j] = nums[j + 1] + nums[j + 1] = tmp + count += 3 # 원소 교환에는 단위 연산 3회가 포함된다 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 제곱 차수(버블 정렬) */ + int bubbleSort(vector &nums) { + int count = 0; // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.size() - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + int tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + count += 3; // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 제곱 차수(버블 정렬) */ + int bubbleSort(int[] nums) { + int count = 0; // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + int tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + count += 3; // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 제곱 차수(버블 정렬) */ + int BubbleSort(int[] nums) { + int count = 0; // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.Length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + (nums[j + 1], nums[j]) = (nums[j], nums[j + 1]); + count += 3; // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 제곱 차수(버블 정렬) */ + func bubbleSort(nums []int) int { + count := 0 // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i := len(nums) - 1; i > 0; i-- { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j := 0; j < i; j++ { + if nums[j] > nums[j+1] { + // nums[j]와 nums[j + 1]을 교환한다 + tmp := nums[j] + nums[j] = nums[j+1] + nums[j+1] = tmp + count += 3 // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 제곱 차수(버블 정렬) */ + func bubbleSort(nums: inout [Int]) -> Int { + var count = 0 // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in nums.indices.dropFirst().reversed() { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in 0 ..< i { + if nums[j] > nums[j + 1] { + // nums[j]와 nums[j + 1]을 교환한다 + let tmp = nums[j] + nums[j] = nums[j + 1] + nums[j + 1] = tmp + count += 3 // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 제곱 차수(버블 정렬) */ + function bubbleSort(nums) { + let count = 0; // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (let i = nums.length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (let j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + let tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + count += 3; // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 제곱 차수(버블 정렬) */ + function bubbleSort(nums: number[]): number { + let count = 0; // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (let i = nums.length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (let j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + let tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + count += 3; // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 제곱 차수(버블 정렬) */ + int bubbleSort(List nums) { + int count = 0; // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (var i = nums.length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (var j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + int tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + count += 3; // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 제곱 차수(버블 정렬) */ + fn bubble_sort(nums: &mut [i32]) -> i32 { + let mut count = 0; // 카운터 + + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in (1..nums.len()).rev() { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in 0..i { + if nums[j] > nums[j + 1] { + // nums[j]와 nums[j + 1]을 교환한다 + let tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + count += 3; // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + count + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 제곱 차수(버블 정렬) */ + int bubbleSort(int *nums, int n) { + int count = 0; // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = n - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + int tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + count += 3; // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 제곱 차수(버블 정렬) */ + fun bubbleSort(nums: IntArray): Int { + var count = 0 // 카운터 + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (i in nums.size - 1 downTo 1) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (j in 0.. nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + val temp = nums[j] + nums[j] = nums[j + 1] + nums[j + 1] = temp + count += 3 // 원소 교환에는 단위 연산 3회가 포함된다 + } + } + } + return count + } + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + ### 제곱 차수(버블 정렬)### + def bubble_sort(nums) + count = 0 # 카운터 + + # 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in (nums.length - 1).downto(0) + # 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in 0...i + if nums[j] > nums[j + 1] + # nums[j]와 nums[j + 1]을 교환한다 + tmp = nums[j] + nums[j] = nums[j + 1] + nums[j + 1] = tmp + count += 3 # 원소 교환에는 단위 연산 3회가 포함된다 + end + end + end + + count + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 4.   지수 시간 $O(2^n)$ {data-toc-label="4.   지수 시간"} + +생물학의 ‘세포 분열’은 지수적 증가의 대표적인 예다. 처음에는 세포가 $1$개이고, 한 차례 분열하면 $2$개, 두 차례 분열하면 $4$개가 된다. 이처럼 $n$차례 분열하면 세포는 $2^n$개가 된다. + +아래 그림(2-11)과 코드는 세포 분열 과정을 재현하며, 시간 복잡도는 $O(2^n)$이다. 입력 $n$은 분열이 진행되는 라운드 수이고, 반환값 `count`는 전체 분열 횟수라는 점에 주의하자. + +=== "Python" + + ```python title="time_complexity.py" + def exponential(n: int) -> int: + """지수 차수(반복문 구현)""" + count = 0 + base = 1 + # 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for _ in range(n): + for _ in range(base): + count += 1 + base *= 2 + # count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 지수 차수(반복문 구현) */ + int exponential(int n) { + int count = 0, base = 1; + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for (int i = 0; i < n; i++) { + for (int j = 0; j < base; j++) { + count++; + } + base *= 2; + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 지수 차수(반복문 구현) */ + int exponential(int n) { + int count = 0, base = 1; + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for (int i = 0; i < n; i++) { + for (int j = 0; j < base; j++) { + count++; + } + base *= 2; + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 지수 차수(반복문 구현) */ + int Exponential(int n) { + int count = 0, bas = 1; + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for (int i = 0; i < n; i++) { + for (int j = 0; j < bas; j++) { + count++; + } + bas *= 2; + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 지수 차수(반복문 구현)*/ + func exponential(n int) int { + count, base := 0, 1 + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for i := 0; i < n; i++ { + for j := 0; j < base; j++ { + count++ + } + base *= 2 + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 지수 차수(반복문 구현) */ + func exponential(n: Int) -> Int { + var count = 0 + var base = 1 + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for _ in 0 ..< n { + for _ in 0 ..< base { + count += 1 + } + base *= 2 + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 지수 차수(반복문 구현) */ + function exponential(n) { + let count = 0, + base = 1; + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for (let i = 0; i < n; i++) { + for (let j = 0; j < base; j++) { + count++; + } + base *= 2; + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 지수 차수(반복문 구현) */ + function exponential(n: number): number { + let count = 0, + base = 1; + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for (let i = 0; i < n; i++) { + for (let j = 0; j < base; j++) { + count++; + } + base *= 2; + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 지수 차수(반복문 구현) */ + int exponential(int n) { + int count = 0, base = 1; + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for (var i = 0; i < n; i++) { + for (var j = 0; j < base; j++) { + count++; + } + base *= 2; + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 지수 차수(반복문 구현) */ + fn exponential(n: i32) -> i32 { + let mut count = 0; + let mut base = 1; + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for _ in 0..n { + for _ in 0..base { + count += 1 + } + base *= 2; + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + count + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 지수 차수(반복문 구현) */ + int exponential(int n) { + int count = 0; + int bas = 1; + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for (int i = 0; i < n; i++) { + for (int j = 0; j < bas; j++) { + count++; + } + bas *= 2; + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 지수 차수(반복문 구현) */ + fun exponential(n: Int): Int { + var count = 0 + var base = 1 + // 세포가 매번 둘로 나뉘어 수열 1, 2, 4, 8, ..., 2^(n-1)을 이룬다 + for (i in 0.. + + +![지수 시간 복잡도](time_complexity.assets/time_complexity_exponential.png){ class="animation-figure" } + +

그림 2-11   지수 시간 복잡도

+ +실제 알고리즘에서 지수 시간은 재귀 함수에 자주 나타난다. 예를 들어 다음 코드는 재귀적으로 둘로 나뉘며, $n$번 나뉜 뒤 멈춘다. + +=== "Python" + + ```python title="time_complexity.py" + def exp_recur(n: int) -> int: + """지수 차수(재귀 구현)""" + if n == 1: + return 1 + return exp_recur(n - 1) + exp_recur(n - 1) + 1 + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 지수 차수(재귀 구현) */ + int expRecur(int n) { + if (n == 1) + return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 지수 차수(재귀 구현) */ + int expRecur(int n) { + if (n == 1) + return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 지수 차수(재귀 구현) */ + int ExpRecur(int n) { + if (n == 1) return 1; + return ExpRecur(n - 1) + ExpRecur(n - 1) + 1; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 지수 차수(재귀 구현)*/ + func expRecur(n int) int { + if n == 1 { + return 1 + } + return expRecur(n-1) + expRecur(n-1) + 1 + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 지수 차수(재귀 구현) */ + func expRecur(n: Int) -> Int { + if n == 1 { + return 1 + } + return expRecur(n: n - 1) + expRecur(n: n - 1) + 1 + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 지수 차수(재귀 구현) */ + function expRecur(n) { + if (n === 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 지수 차수(재귀 구현) */ + function expRecur(n: number): number { + if (n === 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 지수 차수(재귀 구현) */ + int expRecur(int n) { + if (n == 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 지수 차수(재귀 구현) */ + fn exp_recur(n: i32) -> i32 { + if n == 1 { + return 1; + } + exp_recur(n - 1) + exp_recur(n - 1) + 1 + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 지수 차수(재귀 구현) */ + int expRecur(int n) { + if (n == 1) + return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 지수 차수(재귀 구현) */ + fun expRecur(n: Int): Int { + if (n == 1) { + return 1 + } + return expRecur(n - 1) + expRecur(n - 1) + 1 + } + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + ### 지수 차수(재귀 구현)### + def exp_recur(n) + return 1 if n == 1 + exp_recur(n - 1) + exp_recur(n - 1) + 1 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +지수 시간은 매우 빠르게 증가하며 완전 열거(완전 탐색, 백트래킹 등)에 흔히 나타난다. 데이터가 많은 문제에서는 지수 시간을 감당하기 어려우므로, 보통 동적 계획법이나 탐욕 알고리즘 등으로 해결해야 한다. + +### 5.   로그 시간 $O(\log n)$ {data-toc-label="5.   로그 시간"} + +지수 시간과 반대로 로그 시간은 ‘매번 절반으로 줄어드는’ 상황을 나타낸다. 입력 데이터 크기가 $n$이고 매번 절반으로 줄어들므로, 반복 횟수는 $2^n$의 역함수인 $\log_2 n$이다. + +아래 그림(2-12)과 코드는 ‘매번 절반으로 줄어드는’ 과정을 재현한다. 시간 복잡도는 $O(\log_2 n)$이며, 줄여서 $O(\log n)$으로 쓴다. + +=== "Python" + + ```python title="time_complexity.py" + def logarithmic(n: int) -> int: + """로그 차수(반복문 구현)""" + count = 0 + while n > 1: + n = n / 2 + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 로그 차수(반복문 구현) */ + int logarithmic(int n) { + int count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 로그 차수(반복문 구현) */ + int logarithmic(int n) { + int count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 로그 차수(반복문 구현) */ + int Logarithmic(int n) { + int count = 0; + while (n > 1) { + n /= 2; + count++; + } + return count; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 로그 차수(반복문 구현)*/ + func logarithmic(n int) int { + count := 0 + for n > 1 { + n = n / 2 + count++ + } + return count + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 로그 차수(반복문 구현) */ + func logarithmic(n: Int) -> Int { + var count = 0 + var n = n + while n > 1 { + n = n / 2 + count += 1 + } + return count + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 로그 차수(반복문 구현) */ + function logarithmic(n) { + let count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 로그 차수(반복문 구현) */ + function logarithmic(n: number): number { + let count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 로그 차수(반복문 구현) */ + int logarithmic(int n) { + int count = 0; + while (n > 1) { + n = n ~/ 2; + count++; + } + return count; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 로그 차수(반복문 구현) */ + fn logarithmic(mut n: i32) -> i32 { + let mut count = 0; + while n > 1 { + n = n / 2; + count += 1; + } + count + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 로그 차수(반복문 구현) */ + int logarithmic(int n) { + int count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 로그 차수(반복문 구현) */ + fun logarithmic(n: Int): Int { + var n1 = n + var count = 0 + while (n1 > 1) { + n1 /= 2 + count++ + } + return count + } + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + ### 로그 차수(반복문 구현)### + def logarithmic(n) + count = 0 + + while n > 1 + n /= 2 + count += 1 + end + + count + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +![로그 시간 복잡도](time_complexity.assets/time_complexity_logarithmic.png){ class="animation-figure" } + +

그림 2-12   로그 시간 복잡도

+ +지수 시간처럼 로그 시간도 재귀 함수에 자주 나타난다. 다음 코드는 높이가 $\log_2 n$인 재귀 트리를 만든다. + +=== "Python" + + ```python title="time_complexity.py" + def log_recur(n: int) -> int: + """로그 차수(재귀 구현)""" + if n <= 1: + return 0 + return log_recur(n / 2) + 1 + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 로그 차수(재귀 구현) */ + int logRecur(int n) { + if (n <= 1) + return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 로그 차수(재귀 구현) */ + int logRecur(int n) { + if (n <= 1) + return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 로그 차수(재귀 구현) */ + int LogRecur(int n) { + if (n <= 1) return 0; + return LogRecur(n / 2) + 1; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 로그 차수(재귀 구현)*/ + func logRecur(n int) int { + if n <= 1 { + return 0 + } + return logRecur(n/2) + 1 + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 로그 차수(재귀 구현) */ + func logRecur(n: Int) -> Int { + if n <= 1 { + return 0 + } + return logRecur(n: n / 2) + 1 + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 로그 차수(재귀 구현) */ + function logRecur(n) { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 로그 차수(재귀 구현) */ + function logRecur(n: number): number { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 로그 차수(재귀 구현) */ + int logRecur(int n) { + if (n <= 1) return 0; + return logRecur(n ~/ 2) + 1; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 로그 차수(재귀 구현) */ + fn log_recur(n: i32) -> i32 { + if n <= 1 { + return 0; + } + log_recur(n / 2) + 1 + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 로그 차수(재귀 구현) */ + int logRecur(int n) { + if (n <= 1) + return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 로그 차수(재귀 구현) */ + fun logRecur(n: Int): Int { + if (n <= 1) + return 0 + return logRecur(n / 2) + 1 + } + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + ### 로그 차수(재귀 구현)### + def log_recur(n) + return 0 unless n > 1 + log_recur(n / 2) + 1 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +로그 시간은 분할 정복 전략에 기반한 알고리즘에 자주 나타나며, ‘하나를 여러 개로 나누기’, ‘복잡한 것을 단순하게 만들기’라는 알고리즘 사고방식을 보여 준다. 증가 속도가 느려 상수 시간 다음으로 이상적인 시간 복잡도다. + +!!! tip "$O(\log n)$의 밑은 무엇인가?" + + 정확히 말하면, ‘하나를 $m$개로 나누기’에 해당하는 시간 복잡도는 $O(\log_m n)$이다. 로그의 밑 변환 공식을 적용하면 밑이 달라도 같은 시간 복잡도를 얻는다. + + $$ + O(\log_m n) = O(\log_k n / \log_k m) = O(\log_k n) + $$ + + 즉, 밑 $m$은 복잡도에 영향을 주지 않고 바꿀 수 있다. 따라서 보통 밑 $m$을 생략하고 로그 시간을 $O(\log n)$으로 표기한다. + +### 6.   선형 로그 시간 $O(n \log n)$ {data-toc-label="6.   선형 로그 시간"} + +선형 로그 시간은 중첩 반복문에 자주 나타나며, 두 반복문의 시간 복잡도는 각각 $O(\log n)$과 $O(n)$이다. 관련 코드는 다음과 같다. + +=== "Python" + + ```python title="time_complexity.py" + def linear_log_recur(n: int) -> int: + """선형 로그 차수""" + if n <= 1: + return 1 + # 둘로 나누어 부분 문제의 크기를 절반으로 줄인다 + count = linear_log_recur(n // 2) + linear_log_recur(n // 2) + # 현재 부분 문제에는 n회의 연산이 포함된다 + for _ in range(n): + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 선형 로그 차수 */ + int linearLogRecur(int n) { + if (n <= 1) + return 1; + int count = linearLogRecur(n / 2) + linearLogRecur(n / 2); + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 선형 로그 차수 */ + int linearLogRecur(int n) { + if (n <= 1) + return 1; + int count = linearLogRecur(n / 2) + linearLogRecur(n / 2); + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 선형 로그 차수 */ + int LinearLogRecur(int n) { + if (n <= 1) return 1; + int count = LinearLogRecur(n / 2) + LinearLogRecur(n / 2); + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 선형 로그 차수 */ + func linearLogRecur(n int) int { + if n <= 1 { + return 1 + } + count := linearLogRecur(n/2) + linearLogRecur(n/2) + for i := 0; i < n; i++ { + count++ + } + return count + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 선형 로그 차수 */ + func linearLogRecur(n: Int) -> Int { + if n <= 1 { + return 1 + } + var count = linearLogRecur(n: n / 2) + linearLogRecur(n: n / 2) + for _ in stride(from: 0, to: n, by: 1) { + count += 1 + } + return count + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 선형 로그 차수 */ + function linearLogRecur(n) { + if (n <= 1) return 1; + let count = linearLogRecur(n / 2) + linearLogRecur(n / 2); + for (let i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 선형 로그 차수 */ + function linearLogRecur(n: number): number { + if (n <= 1) return 1; + let count = linearLogRecur(n / 2) + linearLogRecur(n / 2); + for (let i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 선형 로그 차수 */ + int linearLogRecur(int n) { + if (n <= 1) return 1; + int count = linearLogRecur(n ~/ 2) + linearLogRecur(n ~/ 2); + for (var i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 선형 로그 차수 */ + fn linear_log_recur(n: i32) -> i32 { + if n <= 1 { + return 1; + } + let mut count = linear_log_recur(n / 2) + linear_log_recur(n / 2); + for _ in 0..n { + count += 1; + } + return count; + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 선형 로그 차수 */ + int linearLogRecur(int n) { + if (n <= 1) + return 1; + int count = linearLogRecur(n / 2) + linearLogRecur(n / 2); + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 선형 로그 차수 */ + fun linearLogRecur(n: Int): Int { + if (n <= 1) + return 1 + var count = linearLogRecur(n / 2) + linearLogRecur(n / 2) + for (i in 0.. 1 + + count = linear_log_recur(n / 2) + linear_log_recur(n / 2) + (0...n).each { count += 1 } + + count + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(2-13)은 선형 로그 시간이 만들어지는 방식을 보여 준다. 이진 트리의 각 층에서 수행하는 전체 연산 횟수는 $n$이고, 층수는 $\log_2 n + 1$이므로 시간 복잡도는 $O(n \log n)$이다. + +![선형 로그 시간 복잡도](time_complexity.assets/time_complexity_logarithmic_linear.png){ class="animation-figure" } + +

그림 2-13   선형 로그 시간 복잡도

+ +퀵 정렬, 병합 정렬, 힙 정렬 등 널리 쓰이는 정렬 알고리즘의 시간 복잡도는 보통 $O(n \log n)$이다. + +### 7.   팩토리얼 시간 $O(n!)$ {data-toc-label="7.   팩토리얼 시간"} + +팩토리얼 시간은 수학의 ‘모든 순열 구하기’ 문제에 해당한다. 서로 다른 원소 $n$개가 주어졌을 때 가능한 모든 순열을 구하면, 그 개수는 다음과 같다. + +$$ +n! = n \times (n - 1) \times (n - 2) \times \dots \times 2 \times 1 +$$ + +팩토리얼은 보통 재귀로 구현한다. 아래 그림(2-14)과 코드에서 첫 번째 층은 $n$개로, 두 번째 층은 $n - 1$개로 분기하며, 이를 반복하다가 $n$번째 층에 도달하면 분기를 멈춘다. + +=== "Python" + + ```python title="time_complexity.py" + def factorial_recur(n: int) -> int: + """팩토리얼 차수(재귀 구현)""" + if n == 0: + return 1 + count = 0 + # 하나에서 n개로 분열한다 + for _ in range(n): + count += factorial_recur(n - 1) + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 팩토리얼 차수(재귀 구현) */ + int factorialRecur(int n) { + if (n == 0) + return 1; + int count = 0; + // 하나에서 n개로 분열한다 + for (int i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 팩토리얼 차수(재귀 구현) */ + int factorialRecur(int n) { + if (n == 0) + return 1; + int count = 0; + // 하나에서 n개로 분열한다 + for (int i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 팩토리얼 차수(재귀 구현) */ + int FactorialRecur(int n) { + if (n == 0) return 1; + int count = 0; + // 하나에서 n개로 분열한다 + for (int i = 0; i < n; i++) { + count += FactorialRecur(n - 1); + } + return count; + } + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 팩토리얼 차수(재귀 구현) */ + func factorialRecur(n int) int { + if n == 0 { + return 1 + } + count := 0 + // 하나에서 n개로 분열한다 + for i := 0; i < n; i++ { + count += factorialRecur(n - 1) + } + return count + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 팩토리얼 차수(재귀 구현) */ + func factorialRecur(n: Int) -> Int { + if n == 0 { + return 1 + } + var count = 0 + // 하나에서 n개로 분열한다 + for _ in 0 ..< n { + count += factorialRecur(n: n - 1) + } + return count + } + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + /* 팩토리얼 차수(재귀 구현) */ + function factorialRecur(n) { + if (n === 0) return 1; + let count = 0; + // 하나에서 n개로 분열한다 + for (let i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + /* 팩토리얼 차수(재귀 구현) */ + function factorialRecur(n: number): number { + if (n === 0) return 1; + let count = 0; + // 하나에서 n개로 분열한다 + for (let i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + /* 팩토리얼 차수(재귀 구현) */ + int factorialRecur(int n) { + if (n == 0) return 1; + int count = 0; + // 하나에서 n개로 분열한다 + for (var i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + /* 팩토리얼 차수(재귀 구현) */ + fn factorial_recur(n: i32) -> i32 { + if n == 0 { + return 1; + } + let mut count = 0; + // 하나에서 n개로 분열한다 + for _ in 0..n { + count += factorial_recur(n - 1); + } + count + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 팩토리얼 차수(재귀 구현) */ + int factorialRecur(int n) { + if (n == 0) + return 1; + int count = 0; + for (int i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + /* 팩토리얼 차수(재귀 구현) */ + fun factorialRecur(n: Int): Int { + if (n == 0) + return 1 + var count = 0 + // 하나에서 n개로 분열한다 + for (i in 0.. + + +![팩토리얼 시간 복잡도](time_complexity.assets/time_complexity_factorial.png){ class="animation-figure" } + +

그림 2-14   팩토리얼 시간 복잡도

+ +$n \geq 4$이면 항상 $n! > 2^n$이므로 팩토리얼 시간은 지수 시간보다 더 빠르게 증가한다. 따라서 $n$이 클 때는 이 역시 감당하기 어렵다. + +## 2.3.5   최악, 최선, 평균 시간 복잡도 + +**알고리즘의 시간 효율은 고정되어 있지 않고 입력 데이터의 분포에 따라 달라지는 경우가 많다**. 길이가 $n$인 배열 `nums`를 입력받는다고 하자. `nums`에는 $1$부터 $n$까지의 수가 한 번씩 들어 있으며, 원소 순서는 무작위로 섞여 있다. 목표는 원소 $1$의 인덱스를 반환하는 것이다. 여기서 다음과 같은 결론을 얻는다. + +- `nums = [?, ?, ..., 1]`, 즉 마지막 원소가 $1$이면 배열 전체를 순회해야 하므로 **최악 시간 복잡도 $O(n)$에 도달한다**. +- `nums = [1, ?, ?, ...]`, 즉 첫 번째 원소가 $1$이면 배열 길이에 관계없이 더 순회할 필요가 없으므로 **최선 시간 복잡도 $\Omega(1)$에 도달한다**. + +‘최악 시간 복잡도’는 함수의 점근적 상한에 해당하며 빅 $O$ 표기법으로 나타낸다. 이에 대응하여 ‘최선 시간 복잡도’는 함수의 점근적 하한에 해당하며 $\Omega$ 표기법으로 나타낸다. + +=== "Python" + + ```python title="worst_best_time_complexity.py" + def random_numbers(n: int) -> list[int]: + """원소가 1, 2, ..., n이며 순서가 섞인 배열 생성""" + # 배열 nums =: 1, 2, 3, ..., n을 생성한다 + nums = [i for i in range(1, n + 1)] + # 배열 원소를 무작위로 섞는다 + random.shuffle(nums) + return nums + + def find_one(nums: list[int]) -> int: + """배열 nums에서 숫자 1이 있는 인덱스 탐색""" + for i in range(len(nums)): + # 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + # 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if nums[i] == 1: + return i + return -1 + ``` + +=== "C++" + + ```cpp title="worst_best_time_complexity.cpp" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + vector randomNumbers(int n) { + vector nums(n); + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + for (int i = 0; i < n; i++) { + nums[i] = i + 1; + } + // 시스템 시간으로 난수 시드를 생성한다 + unsigned seed = chrono::system_clock::now().time_since_epoch().count(); + // 배열 원소를 무작위로 섞는다 + shuffle(nums.begin(), nums.end(), default_random_engine(seed)); + return nums; + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + int findOne(vector &nums) { + for (int i = 0; i < nums.size(); i++) { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if (nums[i] == 1) + return i; + } + return -1; + } + ``` + +=== "Java" + + ```java title="worst_best_time_complexity.java" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + int[] randomNumbers(int n) { + Integer[] nums = new Integer[n]; + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + for (int i = 0; i < n; i++) { + nums[i] = i + 1; + } + // 배열 원소를 무작위로 섞는다 + Collections.shuffle(Arrays.asList(nums)); + // Integer[] -> int[] + int[] res = new int[n]; + for (int i = 0; i < n; i++) { + res[i] = nums[i]; + } + return res; + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + int findOne(int[] nums) { + for (int i = 0; i < nums.length; i++) { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if (nums[i] == 1) + return i; + } + return -1; + } + ``` + +=== "C#" + + ```csharp title="worst_best_time_complexity.cs" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + int[] RandomNumbers(int n) { + int[] nums = new int[n]; + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + for (int i = 0; i < n; i++) { + nums[i] = i + 1; + } + + // 배열 원소를 무작위로 섞는다 + for (int i = 0; i < nums.Length; i++) { + int index = new Random().Next(i, nums.Length); + (nums[i], nums[index]) = (nums[index], nums[i]); + } + return nums; + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + int FindOne(int[] nums) { + for (int i = 0; i < nums.Length; i++) { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if (nums[i] == 1) + return i; + } + return -1; + } + ``` + +=== "Go" + + ```go title="worst_best_time_complexity.go" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + func randomNumbers(n int) []int { + nums := make([]int, n) + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + for i := 0; i < n; i++ { + nums[i] = i + 1 + } + // 배열 원소를 무작위로 섞는다 + rand.Shuffle(len(nums), func(i, j int) { + nums[i], nums[j] = nums[j], nums[i] + }) + return nums + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + func findOne(nums []int) int { + for i := 0; i < len(nums); i++ { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if nums[i] == 1 { + return i + } + } + return -1 + } + ``` + +=== "Swift" + + ```swift title="worst_best_time_complexity.swift" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + func randomNumbers(n: Int) -> [Int] { + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + var nums = Array(1 ... n) + // 배열 원소를 무작위로 섞는다 + nums.shuffle() + return nums + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + func findOne(nums: [Int]) -> Int { + for i in nums.indices { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if nums[i] == 1 { + return i + } + } + return -1 + } + ``` + +=== "JS" + + ```javascript title="worst_best_time_complexity.js" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + function randomNumbers(n) { + const nums = Array(n); + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + for (let i = 0; i < n; i++) { + nums[i] = i + 1; + } + // 배열 원소를 무작위로 섞는다 + for (let i = 0; i < n; i++) { + const r = Math.floor(Math.random() * (i + 1)); + const temp = nums[i]; + nums[i] = nums[r]; + nums[r] = temp; + } + return nums; + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + function findOne(nums) { + for (let i = 0; i < nums.length; i++) { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if (nums[i] === 1) { + return i; + } + } + return -1; + } + ``` + +=== "TS" + + ```typescript title="worst_best_time_complexity.ts" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + function randomNumbers(n: number): number[] { + const nums = Array(n); + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + for (let i = 0; i < n; i++) { + nums[i] = i + 1; + } + // 배열 원소를 무작위로 섞는다 + for (let i = 0; i < n; i++) { + const r = Math.floor(Math.random() * (i + 1)); + const temp = nums[i]; + nums[i] = nums[r]; + nums[r] = temp; + } + return nums; + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + function findOne(nums: number[]): number { + for (let i = 0; i < nums.length; i++) { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if (nums[i] === 1) { + return i; + } + } + return -1; + } + ``` + +=== "Dart" + + ```dart title="worst_best_time_complexity.dart" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + List randomNumbers(int n) { + final nums = List.filled(n, 0); + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + for (var i = 0; i < n; i++) { + nums[i] = i + 1; + } + // 배열 원소를 무작위로 섞는다 + nums.shuffle(); + + return nums; + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + int findOne(List nums) { + for (var i = 0; i < nums.length; i++) { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if (nums[i] == 1) return i; + } + + return -1; + } + ``` + +=== "Rust" + + ```rust title="worst_best_time_complexity.rs" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + fn random_numbers(n: i32) -> Vec { + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + let mut nums = (1..=n).collect::>(); + // 배열 원소를 무작위로 섞는다 + nums.shuffle(&mut thread_rng()); + nums + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + fn find_one(nums: &[i32]) -> Option { + for i in 0..nums.len() { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if nums[i] == 1 { + return Some(i); + } + } + None + } + ``` + +=== "C" + + ```c title="worst_best_time_complexity.c" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + int *randomNumbers(int n) { + // 힙 메모리를 할당한다(원소 개수가 n이고 원소 타입이 int인 1차원 가변 길이 배열을 생성한다) + int *nums = (int *)malloc(n * sizeof(int)); + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + for (int i = 0; i < n; i++) { + nums[i] = i + 1; + } + // 배열 원소를 무작위로 섞는다 + for (int i = n - 1; i > 0; i--) { + int j = rand() % (i + 1); + int temp = nums[i]; + nums[i] = nums[j]; + nums[j] = temp; + } + return nums; + } + + /* 배열 nums에서 숫자 1이 있는 인덱스 탐색 */ + int findOne(int *nums, int n) { + for (int i = 0; i < n; i++) { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if (nums[i] == 1) + return i; + } + return -1; + } + ``` + +=== "Kotlin" + + ```kotlin title="worst_best_time_complexity.kt" + /* 원소가 { 1, 2, ..., n }이며 순서가 섞인 배열 생성 */ + fun randomNumbers(n: Int): Array { + val nums = IntArray(n) + // 배열 nums = { 1, 2, 3, ..., n }을 생성한다 + for (i in 0..(n) + for (i in 0..): Int { + for (i in nums.indices) { + // 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + // 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + if (nums[i] == 1) + return i + } + return -1 + } + ``` + +=== "Ruby" + + ```ruby title="worst_best_time_complexity.rb" + ### 원소가 1, 2, ..., n이며 순서가 섞인 배열 생성 ### + def random_numbers(n) + # 배열 nums =: 1, 2, 3, ..., n을 생성한다 + nums = Array.new(n) { |i| i + 1 } + # 배열 원소를 무작위로 섞는다 + nums.shuffle! + end + + ### 배열 nums에서 숫자 1이 있는 인덱스 탐색 ### + def find_one(nums) + for i in 0...nums.length + # 원소 1이 배열 맨 앞에 있으면 최선 시간 복잡도 O(1)에 도달한다 + # 원소 1이 배열 맨 뒤에 있으면 최악 시간 복잡도 O(n)에 도달한다 + return i if nums[i] == 1 + end + + -1 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +실제로 최선 시간 복잡도는 거의 사용하지 않는다. 보통 최선의 경우가 발생할 확률이 매우 낮아 오해를 줄 수 있기 때문이다. **최악 시간 복잡도는 효율의 안전한 기준을 제시하므로 더 실용적이며**, 이를 통해 안심하고 알고리즘을 사용할 수 있다. + +앞의 예에서 최악 시간 복잡도와 최선 시간 복잡도는 ‘특수한 데이터 분포’에서만 나타난다. 이러한 경우는 발생 확률이 낮을 수 있어 실제 실행 효율을 제대로 반영하지 못한다. 반면 **평균 시간 복잡도는 무작위 입력 데이터에서의 알고리즘 실행 효율을 나타내며**, $\Theta$ 표기법으로 표시한다. + +일부 알고리즘은 무작위 데이터 분포에서의 평균 상황을 간단히 계산할 수 있다. 앞의 예에서는 입력 배열이 섞여 있으므로 원소 $1$이 어느 인덱스에 있을 확률도 같다. 따라서 평균 반복 횟수는 배열 길이의 절반인 $n / 2$이며, 평균 시간 복잡도는 $\Theta(n / 2) = \Theta(n)$이다. + +하지만 더 복잡한 알고리즘에서는 데이터 분포에 따른 전체 기댓값을 분석하기 어려워 평균 시간 복잡도를 구하기 힘든 경우가 많다. 이럴 때는 보통 최악 시간 복잡도를 알고리즘 효율의 평가 기준으로 삼는다. + +!!! question "$\Theta$ 기호는 왜 보기 드문가?" + + $O$ 기호가 워낙 익숙해서인지 평균 시간 복잡도에도 이를 자주 사용한다. 그러나 엄밀하게는 올바른 표기 방식이 아니다. 이 책이나 다른 자료에서 ‘평균 시간 복잡도 $O(n)$’ 같은 표현을 만나면 $\Theta(n)$으로 이해하면 된다. diff --git a/ko/docs/chapter_data_structure/basic_data_types.md b/ko/docs/chapter_data_structure/basic_data_types.md new file mode 100644 index 000000000..915c76f56 --- /dev/null +++ b/ko/docs/chapter_data_structure/basic_data_types.md @@ -0,0 +1,184 @@ +--- +comments: true +--- + +# 3.2   기본 자료형 + +컴퓨터의 데이터라고 하면 텍스트, 이미지, 동영상, 음성, 3D 모델 등 다양한 형태가 떠오른다. 이 데이터들은 조직된 형태는 서로 다르지만, 모두 여러 기본 자료형으로 이루어져 있다. + +**기본 자료형은 CPU가 직접 연산할 수 있는 자료형**으로, 알고리즘에서 직접 사용된다. 주로 다음과 같은 종류가 있다. + +- 정수형 `byte`, `short`, `int`, `long`. +- 소수를 표현하는 부동 소수점형 `float`, `double`. +- 여러 언어의 글자, 문장 부호, 나아가 이모지 등을 표현하는 문자형 `char`. +- ‘예’와 ‘아니요’를 판단하는 불리언형 `bool`. + +**기본 자료형은 컴퓨터에 이진수 형태로 저장된다**. 이진수 한 자리가 $1$비트다. 현대 운영체제의 대다수에서 $1$바이트(byte)는 $8$비트(bit)로 이루어진다. + +기본 자료형의 값 범위는 차지하는 공간의 크기에 따라 결정된다. Java를 예로 살펴보자. + +- 정수형 `byte`는 $1$바이트 = $8$비트를 차지하며, $2^{8}$개의 수를 표현할 수 있다. +- 정수형 `int`는 $4$바이트 = $32$비트를 차지하며, $2^{32}$개의 수를 표현할 수 있다. + +아래 표(3-1)는 Java의 여러 기본 자료형이 차지하는 공간, 값 범위, 기본값을 나열한다. 표를 외울 필요는 없으며, 대략 이해한 뒤 필요할 때 찾아보면 된다. + +

표 3-1   기본 자료형의 공간 사용량과 값 범위

+ +
+ +| 자료형 | 기호 | 공간 사용량 | 최솟값 | 최댓값 | 기본값 | +| ------ | -------- | -------- | ------------------------ | ----------------------- | -------------- | +| 정수 | `byte` | 1바이트 | $-2^7$ ($-128$) | $2^7 - 1$ ($127$) | $0$ | +| | `short` | 2바이트 | $-2^{15}$ | $2^{15} - 1$ | $0$ | +| | `int` | 4바이트 | $-2^{31}$ | $2^{31} - 1$ | $0$ | +| | `long` | 8바이트 | $-2^{63}$ | $2^{63} - 1$ | $0$ | +| 부동 소수점 | `float` | 4바이트 | $1.175 \times 10^{-38}$ | $3.403 \times 10^{38}$ | $0.0\text{f}$ | +| | `double` | 8바이트 | $2.225 \times 10^{-308}$ | $1.798 \times 10^{308}$ | $0.0$ | +| 문자 | `char` | 2바이트 | $0$ | $2^{16} - 1$ | $0$ | +| 불리언 | `bool` | 1바이트 | $\text{false}$ | $\text{true}$ | $\text{false}$ | + +
+ +위 표(3-1)는 Java의 기본 자료형을 기준으로 한다. 프로그래밍 언어마다 자료형을 정의하는 방식이 있으므로, 공간 사용량과 값 범위, 기본값은 다를 수 있다. + +- Python의 정수형 `int`는 사용 가능한 메모리만 충분하면 크기에 제한이 없다. 부동 소수점형 `float`는 64비트 배정밀도다. `char` 자료형은 없으며, 문자 하나는 실제로 길이가 1인 문자열 `str`이다. +- C와 C++는 기본 자료형의 크기를 명확히 규정하지 않으므로 구현과 플랫폼에 따라 다르다. 위 표(3-1)는 Linux, macOS 등의 64비트 Unix 운영체제에서 사용하는 LP64 [데이터 모델](https://en.cppreference.com/w/cpp/language/types#Properties)을 따른다. +- 문자형 `char`의 크기는 C와 C++에서 1바이트이며, 대부분의 프로그래밍 언어에서는 사용하는 문자 인코딩 방식에 따라 정해진다. 자세한 내용은 ‘문자 인코딩’ 절에서 설명한다. +- 불리언 값을 표현하는 데는 1비트($0$ 또는 $1$)면 충분하지만, 메모리에서는 보통 1바이트로 저장한다. 현대 컴퓨터 CPU가 대개 1바이트를 주소 지정이 가능한 최소 메모리 단위로 사용하기 때문이다. + +그렇다면 기본 자료형과 자료 구조는 어떤 관계가 있을까? 자료 구조란 컴퓨터에서 데이터를 조직하고 저장하는 방식이다. 이 문장의 핵심은 ‘데이터’보다 ‘구조’에 있다. + +‘한 줄로 늘어선 수’를 표현하려면 자연스럽게 배열을 떠올린다. 배열의 선형 구조가 수 사이의 인접 관계와 순서 관계를 표현할 수 있기 때문이다. 그러나 저장할 내용이 정수 `int`인지, 소수 `float`인지, 문자 `char`인지는 ‘자료 구조’와 무관하다. + +즉, **기본 자료형은 데이터의 ‘내용 유형’을 제공하고, 자료 구조는 데이터의 ‘조직 방식’을 제공한다**. 다음 코드는 같은 자료 구조인 배열을 사용하여 `int`, `float`, `char`, `bool` 등 서로 다른 기본 자료형을 저장하고 표현한다. + +=== "Python" + + ```python title="" + # 여러 기본 자료형으로 배열 초기화 + numbers: list[int] = [0] * 5 + decimals: list[float] = [0.0] * 5 + # Python의 문자는 실제로 길이가 1인 문자열이다 + characters: list[str] = ['0'] * 5 + bools: list[bool] = [False] * 5 + # Python의 리스트는 여러 기본 자료형과 객체 참조를 자유롭게 저장할 수 있다 + data = [0, 0.0, 'a', False, ListNode(0)] + ``` + +=== "C++" + + ```cpp title="" + // 여러 기본 자료형으로 배열 초기화 + int numbers[5]; + float decimals[5]; + char characters[5]; + bool bools[5]; + ``` + +=== "Java" + + ```java title="" + // 여러 기본 자료형으로 배열 초기화 + int[] numbers = new int[5]; + float[] decimals = new float[5]; + char[] characters = new char[5]; + boolean[] bools = new boolean[5]; + ``` + +=== "C#" + + ```csharp title="" + // 여러 기본 자료형으로 배열 초기화 + int[] numbers = new int[5]; + float[] decimals = new float[5]; + char[] characters = new char[5]; + bool[] bools = new bool[5]; + ``` + +=== "Go" + + ```go title="" + // 여러 기본 자료형으로 배열 초기화 + var numbers = [5]int{} + var decimals = [5]float64{} + var characters = [5]byte{} + var bools = [5]bool{} + ``` + +=== "Swift" + + ```swift title="" + // 여러 기본 자료형으로 배열 초기화 + let numbers = Array(repeating: 0, count: 5) + let decimals = Array(repeating: 0.0, count: 5) + let characters: [Character] = Array(repeating: "a", count: 5) + let bools = Array(repeating: false, count: 5) + ``` + +=== "JS" + + ```javascript title="" + // JavaScript의 배열은 여러 기본 자료형과 객체를 자유롭게 저장할 수 있다 + const array = [0, 0.0, 'a', false]; + ``` + +=== "TS" + + ```typescript title="" + // 여러 기본 자료형으로 배열 초기화 + const numbers: number[] = []; + const characters: string[] = []; + const bools: boolean[] = []; + ``` + +=== "Dart" + + ```dart title="" + // 여러 기본 자료형으로 배열 초기화 + List numbers = List.filled(5, 0); + List decimals = List.filled(5, 0.0); + List characters = List.filled(5, 'a'); + List bools = List.filled(5, false); + ``` + +=== "Rust" + + ```rust title="" + // 여러 기본 자료형으로 배열 초기화 + let numbers: Vec = vec![0; 5]; + let decimals: Vec = vec![0.0; 5]; + let characters: Vec = vec!['0'; 5]; + let bools: Vec = vec![false; 5]; + ``` + +=== "C" + + ```c title="" + // 여러 기본 자료형으로 배열 초기화 + int numbers[10]; + float decimals[10]; + char characters[10]; + bool bools[10]; + ``` + +=== "Kotlin" + + ```kotlin title="" + // 여러 기본 자료형으로 배열 초기화 + val numbers = IntArray(5) + val decinals = FloatArray(5) + val characters = CharArray(5) + val bools = BooleanArray(5) + ``` + +=== "Ruby" + + ```ruby title="" + # Ruby의 리스트는 여러 기본 자료형과 객체 참조를 자유롭게 저장할 수 있다 + data = [0, 0.0, 'a', false, ListNode(0)] + ``` + +??? pythontutor "코드 시각화" + +
+ diff --git a/ko/docs/chapter_data_structure/character_encoding.md b/ko/docs/chapter_data_structure/character_encoding.md new file mode 100644 index 000000000..73e3a08c0 --- /dev/null +++ b/ko/docs/chapter_data_structure/character_encoding.md @@ -0,0 +1,95 @@ +--- +comments: true +--- + +# 3.4   문자 인코딩 * + +컴퓨터에서는 모든 데이터를 이진수 형태로 저장하며, 문자 `char`도 예외가 아니다. 문자를 표현하려면 각 문자와 이진수 사이의 일대일 대응 관계를 정한 ‘문자 집합’이 필요하다. 문자 집합이 있으면 컴퓨터는 대응표를 찾아 이진수를 문자로 바꿀 수 있다. + +## 3.4.1   ASCII 문자 집합 + +ASCII 코드는 가장 초기에 등장한 문자 집합으로, 정식 명칭은 American Standard Code for Information Interchange(미국 정보 교환 표준 부호)다. 7비트 이진수(한 바이트의 하위 7비트)로 문자 하나를 표현하여 최대 128개의 서로 다른 문자를 나타낸다. 아래 그림(3-6)과 같이 영문 대소문자, 숫자 0 ~ 9, 일부 문장 부호와 제어 문자(줄바꿈, 탭 등)를 포함한다. + +![ASCII 코드](character_encoding.assets/ascii_table.png){ class="animation-figure" } + +

그림 3-6   ASCII 코드

+ +그러나 **ASCII 코드는 영어만 표현할 수 있다**. 컴퓨터가 전 세계로 보급되면서 더 많은 언어를 표현할 수 있는 EASCII 문자 집합이 등장했다. ASCII의 7비트를 8비트로 확장하여 서로 다른 문자 256개를 표현한다. + +이후 세계 여러 지역에 맞는 EASCII 문자 집합이 잇따라 등장했다. 이 문자 집합들은 앞의 128개 문자는 모두 ASCII 코드로 통일하고, 뒤의 128개 문자는 각 언어의 필요에 맞게 서로 다르게 정의했다. + +## 3.4.2   GBK 문자 집합 + +하지만 **EASCII 코드도 여러 언어에 필요한 문자 수를 충족하지 못했다**. 예를 들어 한자는 거의 10만 자에 이르며, 일상에서 사용하는 것만 해도 수천 자다. 중국 국가표준총국은 1980년에 한자 6763자를 수록한 GB2312 문자 집합을 발표하여 컴퓨터의 한자 처리에 필요한 기본적인 요구를 충족했다. + +그러나 GB2312는 일부 드문 한자와 번체자를 처리할 수 없었다. GBK 문자 집합은 GB2312를 확장한 것으로 총 21886자의 한자를 수록한다. GBK 인코딩에서는 ASCII 문자를 1바이트로, 한자를 2바이트로 표현한다. + +## 3.4.3   Unicode 문자 집합 + +컴퓨터 기술이 빠르게 발전하면서 문자 집합과 인코딩 표준이 다양하게 등장했지만, 이는 여러 문제를 낳았다. 한편으로는 문자 집합이 대개 특정 언어의 문자만 정의하여 다국어 환경에서 제대로 작동하지 못했다. 다른 한편으로는 같은 언어에도 여러 문자 집합 표준이 있어, 두 컴퓨터의 인코딩 표준이 다르면 정보를 주고받을 때 글자가 깨졌다. + +당시 연구자들은 이렇게 생각했다. **세계의 모든 언어와 기호를 담을 만큼 완전한 문자 집합을 만들면 다국어 환경과 글자 깨짐 문제를 해결할 수 있지 않을까**? 이러한 생각에서 크고 포괄적인 문자 집합 Unicode가 탄생했다. + +Unicode는 중국어로 ‘통일 부호’라는 뜻의 이름으로 불리며, 이론적으로 100만 개 이상의 문자를 수용할 수 있다. 전 세계 문자를 하나의 문자 집합에 통합하고, 여러 언어의 문자를 처리하고 표시하는 공통 문자 집합을 제공하여 인코딩 표준의 차이로 발생하는 글자 깨짐을 줄이는 것이 목표다. 1991년 발표된 뒤로 새로운 언어와 문자가 계속 추가되었다. 2022년 9월 기준으로 Unicode에는 여러 언어의 문자, 기호, 이모지 등을 포함하여 149186개의 문자가 들어 있다. + +Unicode는 범용 문자 집합으로서 각 문자에 고유한 ‘코드 포인트’(문자 번호)를 부여한다. 범위는 U+0000부터 U+10FFFF까지이며, 하나로 통일된 문자 번호 공간을 이룬다. 그러나 **Unicode는 이러한 문자 코드 포인트를 컴퓨터에 어떻게 저장할지는 정하지 않는다**. 길이가 다른 Unicode 코드 포인트들이 한 텍스트에 함께 나타나면 시스템은 문자를 어떻게 해석할까? 예를 들어 길이가 2바이트인 인코딩이 주어졌을 때, 2바이트 문자 하나인지 1바이트 문자 두 개인지 어떻게 판단할까? + +이 문제의 **직접적인 해결책은 모든 문자를 같은 길이로 인코딩하여 저장하는 것이다**. 아래 그림(3-7)에서 ‘Hello’의 각 문자는 1바이트, ‘算法’의 각 문자는 2바이트를 차지한다. 상위 비트에 0을 채우면 ‘Hello 算法’의 모든 문자를 2바이트 길이로 인코딩할 수 있다. 그러면 시스템이 2바이트마다 문자 하나를 해석하여 원래 문구를 복원할 수 있다. + +![Unicode 인코딩 예](character_encoding.assets/unicode_hello_algo.png){ class="animation-figure" } + +

그림 3-7   Unicode 인코딩 예

+ +하지만 ASCII 코드에서 보았듯이 영어 문자를 인코딩하는 데는 1바이트면 충분하다. 위 방식을 사용하면 영어 텍스트가 ASCII 인코딩의 두 배 공간을 차지하여 메모리를 크게 낭비한다. 따라서 더 효율적인 Unicode 인코딩 방법이 필요하다. + +## 3.4.4   UTF-8 인코딩 + +현재 UTF-8은 세계에서 가장 널리 쓰이는 Unicode 인코딩 방식이다. **가변 길이 인코딩으로**, 문자의 복잡성에 따라 1~4바이트로 문자 하나를 표현한다. ASCII 문자는 1바이트, 라틴 문자와 그리스 문자는 2바이트, 자주 쓰는 중국어 문자는 3바이트, 일부 드문 문자는 4바이트가 필요하다. + +UTF-8 인코딩 규칙은 복잡하지 않으며, 다음 두 경우로 나뉜다. + +- 길이가 1바이트인 문자는 최상위 비트를 $0$으로 두고 나머지 7비트에 Unicode 코드 포인트를 넣는다. ASCII 문자는 Unicode 문자 집합의 처음 128개 코드 포인트를 차지한다. 즉, **UTF-8 인코딩은 ASCII 코드와 하위 호환된다**. 따라서 오래된 ASCII 텍스트도 UTF-8로 해석할 수 있다. +- 길이가 $n$바이트인 문자($n > 1$)는 첫 바이트의 상위 $n$비트를 모두 $1$로, $n + 1$번째 비트를 $0$으로 둔다. 두 번째 바이트부터는 각 바이트의 상위 2비트를 $10$으로 두고, 나머지 모든 비트에 문자의 Unicode 코드 포인트를 채운다. + +아래 그림(3-8)은 ‘Hello算法’의 UTF-8 인코딩을 보여 준다. 상위 $n$비트가 모두 $1$이므로 시스템은 맨 앞에서 연속된 $1$의 개수를 읽어 문자 길이가 $n$임을 알 수 있다. + +그렇다면 나머지 바이트의 상위 2비트를 모두 $10$으로 두는 이유는 무엇일까? 이 $10$은 검사 표식 역할을 한다. 시스템이 잘못된 바이트에서 텍스트를 해석하기 시작했을 때, 바이트 앞부분의 $10$을 통해 이상을 빠르게 감지할 수 있다. + +$10$을 검사 표식으로 쓰는 이유는 UTF-8 규칙상 문자 인코딩의 맨 앞 두 비트가 $10$일 수 없기 때문이다. 이는 귀류법으로 증명할 수 있다. 문자 인코딩의 맨 앞 두 비트가 $10$이라고 가정하면, 길이가 $1$바이트인 ASCII 문자라는 뜻이다. 하지만 ASCII 코드의 최상위 비트는 $0$이어야 하므로 가정과 모순된다. + +![UTF-8 인코딩 예](character_encoding.assets/utf-8_hello_algo.png){ class="animation-figure" } + +

그림 3-8   UTF-8 인코딩 예

+ +UTF-8 외에도 다음 두 인코딩 방식이 널리 쓰인다. + +- **UTF-16 인코딩**: 2바이트 또는 4바이트로 문자 하나를 표현한다. 모든 ASCII 문자와 자주 쓰는 비영어권 문자는 2바이트로 표현하고, 일부 문자는 4바이트가 필요하다. 2바이트 문자는 UTF-16 인코딩 값이 Unicode 코드 포인트와 같다. +- **UTF-32 인코딩**: 모든 문자에 4바이트를 사용한다. 따라서 UTF-8이나 UTF-16보다 공간을 더 많이 차지하며, 특히 ASCII 문자의 비중이 큰 텍스트에서 차이가 크다. + +저장 공간 관점에서 UTF-8은 영어 문자를 1바이트만으로 표현하므로 매우 효율적이다. 반면 중국어 같은 일부 비영어권 문자는 UTF-16으로 인코딩하는 것이 더 효율적이다. UTF-16은 2바이트면 되지만 UTF-8은 3바이트가 필요할 수 있기 때문이다. + +호환성 관점에서는 UTF-8의 범용성이 가장 뛰어나며, 많은 도구와 라이브러리가 UTF-8을 우선 지원한다. + +## 3.4.5   프로그래밍 언어의 문자 인코딩 + +과거의 대다수 프로그래밍 언어에서는 실행 중인 프로그램의 문자열에 UTF-16이나 UTF-32 같은 고정 길이 인코딩을 사용했다. 고정 길이 인코딩에서는 문자열을 배열처럼 다룰 수 있어 다음과 같은 장점이 있다. + +- **임의 접근**: UTF-16 문자열은 임의 접근이 쉽다. UTF-8은 가변 길이 인코딩이므로 $i$번째 문자를 찾으려면 문자열의 처음부터 $i$번째 문자까지 순회해야 하며, $O(n)$ 시간이 필요하다. +- **문자 수 계산**: 임의 접근과 마찬가지로 UTF-16 문자열의 길이 계산도 $O(1)$ 연산이다. 반면 UTF-8 문자열의 길이를 계산하려면 문자열 전체를 순회해야 한다. +- **문자열 연산**: UTF-16 문자열에서는 분할, 연결, 삽입, 삭제 등 여러 문자열 연산을 더 쉽게 수행할 수 있다. UTF-8 문자열에서는 잘못된 UTF-8 인코딩이 생기지 않도록 보장하기 위해 보통 추가 계산이 필요하다. + +프로그래밍 언어의 문자 인코딩 설계는 여러 요소가 관련된 흥미로운 주제다. + +- Java의 `String`은 UTF-16 인코딩을 사용하며 각 문자는 2바이트를 차지한다. Java를 처음 설계할 당시에는 16비트로 가능한 모든 문자를 표현할 수 있다고 생각했기 때문이다. 그러나 이는 잘못된 판단이었다. 이후 Unicode 규격이 16비트를 넘어 확장되면서, 현재 Java의 문자는 16비트 값 두 개로 이루어진 ‘서로게이트 쌍’으로 표현될 수 있다. +- JavaScript와 TypeScript의 문자열이 UTF-16을 사용하는 이유도 Java와 비슷하다. Netscape가 1995년에 JavaScript를 처음 발표했을 때 Unicode는 초기 발전 단계였으며, 당시에는 16비트 인코딩으로 모든 Unicode 문자를 표현할 수 있었다. +- C#이 UTF-16을 사용하는 주된 이유는 .NET 플랫폼을 Microsoft가 설계했으며, Windows 운영체제를 비롯한 Microsoft의 여러 기술에서 UTF-16을 널리 사용하기 때문이다. + +이 언어들은 문자 수를 과소평가했기 때문에 16비트를 넘는 Unicode 문자를 표현하려고 ‘서로게이트 쌍’을 도입할 수밖에 없었다. 이는 불가피한 선택이었다. 한편으로는 서로게이트 쌍이 포함된 문자열에서 문자 하나가 2바이트 또는 4바이트를 차지하여 고정 길이 인코딩의 장점을 잃는다. 다른 한편으로는 서로게이트 쌍을 처리하는 코드가 더 필요해 프로그래밍이 복잡해지고 디버깅도 어려워진다. + +이러한 이유로 일부 프로그래밍 언어는 다른 인코딩 방식을 채택했다. + +- Python의 `str`은 Unicode 인코딩과 유연한 문자열 표현을 사용한다. 저장되는 문자 길이는 문자열에서 가장 큰 Unicode 코드 포인트에 따라 정해진다. 모든 문자가 ASCII이면 문자마다 1바이트를 사용한다. ASCII 범위를 넘는 문자가 있지만 모두 기본 다국어 평면(BMP)에 속하면 문자마다 2바이트를 사용한다. BMP를 벗어나는 문자가 있으면 문자마다 4바이트를 사용한다. +- Go의 `string`은 내부적으로 UTF-8 인코딩을 사용한다. Go는 Unicode 코드 포인트 하나를 나타내는 `rune` 자료형도 제공한다. +- Rust의 `str`과 `String`은 내부적으로 UTF-8 인코딩을 사용한다. Rust도 Unicode 코드 포인트 하나를 나타내는 `char` 자료형을 제공한다. + +위 내용은 프로그래밍 언어 안에서 문자열을 저장하는 방식에 관한 것이다. **이는 문자열을 파일에 저장하거나 네트워크로 전송하는 방식과는 별개의 문제다**. 파일 저장이나 네트워크 전송에서는 보통 호환성과 공간 효율을 높이기 위해 문자열을 UTF-8 형식으로 인코딩한다. diff --git a/ko/docs/chapter_data_structure/classification_of_data_structure.md b/ko/docs/chapter_data_structure/classification_of_data_structure.md new file mode 100644 index 000000000..1b51c5eda --- /dev/null +++ b/ko/docs/chapter_data_structure/classification_of_data_structure.md @@ -0,0 +1,58 @@ +--- +comments: true +--- + +# 3.1   자료 구조의 분류 + +대표적인 자료 구조로는 배열, 연결 리스트, 스택, 큐, 해시 테이블, 트리, 힙, 그래프가 있다. 이들은 ‘논리적 구조’와 ‘물리적 구조’라는 두 관점에서 분류할 수 있다. + +## 3.1.1   논리적 구조: 선형과 비선형 + +**논리적 구조는 데이터 원소 사이의 논리적 관계를 드러낸다**. 배열과 연결 리스트에서는 데이터가 일정한 순서로 배열되어 데이터 사이의 선형 관계를 나타낸다. 트리에서는 데이터가 위에서 아래로 계층을 이루어 ‘조상’과 ‘자손’ 사이의 파생 관계를 나타낸다. 그래프는 노드와 간선으로 이루어져 복잡한 네트워크 관계를 표현한다. + +아래 그림(3-1)과 같이 논리적 구조는 크게 ‘선형’과 ‘비선형’으로 나뉜다. 선형 구조는 데이터가 논리적으로 일렬로 배열되어 있어 비교적 직관적이다. 비선형 구조는 이와 달리 일렬로 배열되지 않는다. + +- **선형 자료 구조**: 배열, 연결 리스트, 스택, 큐, 해시 테이블이 해당하며, 원소 사이에 일대일의 순서 관계가 있다. +- **비선형 자료 구조**: 트리, 힙, 그래프, 해시 테이블이 해당한다. + +비선형 자료 구조는 다시 트리 구조와 네트워크 구조로 나뉜다. + +- **트리 구조**: 트리, 힙, 해시 테이블이 해당하며, 원소 사이에 일대다 관계가 있다. +- **네트워크 구조**: 그래프가 해당하며, 원소 사이에 다대다 관계가 있다. + +![선형 자료 구조와 비선형 자료 구조](classification_of_data_structure.assets/classification_logic_structure.png){ class="animation-figure" } + +

그림 3-1   선형 자료 구조와 비선형 자료 구조

+ +## 3.1.2   물리적 구조: 연속과 분산 + +**알고리즘 프로그램이 실행될 때 처리 중인 데이터는 주로 메모리에 저장된다**. 아래 그림(3-2)은 컴퓨터 메모리 모듈을 보여 주며, 검은색 칩마다 메모리 공간이 들어 있다. 메모리는 각 셀에 일정한 크기의 데이터를 저장할 수 있는 거대한 Excel 표라고 생각할 수 있다. + +**시스템은 메모리 주소를 통해 원하는 위치의 데이터에 접근한다**. 아래 그림(3-2)과 같이 컴퓨터는 특정 규칙에 따라 표의 각 셀에 번호를 부여하여 모든 메모리 공간이 고유한 메모리 주소를 갖게 한다. 프로그램은 이 주소를 이용해 메모리의 데이터에 접근한다. + +![메모리 모듈, 메모리 공간, 메모리 주소](classification_of_data_structure.assets/computer_memory_location.png){ class="animation-figure" } + +

그림 3-2   메모리 모듈, 메모리 공간, 메모리 주소

+ +!!! tip "참고" + + 메모리를 Excel 표에 빗댄 것은 단순화한 비유다. 실제 메모리의 작동 방식은 더 복잡하며 주소 공간, 메모리 관리, 캐시, 가상 메모리, 물리 메모리 등의 개념이 관련된다. + +메모리는 모든 프로그램이 공유하는 자원이다. 어떤 메모리 영역을 한 프로그램이 사용하면 보통 다른 프로그램은 동시에 사용할 수 없다. **따라서 자료 구조와 알고리즘을 설계할 때 메모리 자원은 중요한 고려 사항이다**. 예를 들어 알고리즘의 최대 메모리 사용량은 시스템의 남은 메모리를 넘지 않아야 한다. 연속된 큰 메모리 공간이 부족하다면 분산된 메모리 공간에 저장할 수 있는 자료 구조를 선택해야 한다. + +아래 그림(3-3)과 같이 **물리적 구조는 데이터가 컴퓨터 메모리에 저장되는 방식을 나타내며**, 연속 공간 저장(배열)과 분산 공간 저장(연결 리스트)으로 나뉜다. 물리적 구조는 데이터 접근, 갱신, 삽입, 삭제 등의 연산 방식을 근본적으로 결정한다. 두 물리적 구조는 시간 효율과 공간 효율에서 서로 보완적인 특징을 지닌다. + +![연속 공간 저장과 분산 공간 저장](classification_of_data_structure.assets/classification_phisical_structure.png){ class="animation-figure" } + +

그림 3-3   연속 공간 저장과 분산 공간 저장

+ +**모든 자료 구조는 배열, 연결 리스트 또는 둘의 조합으로 구현된다**는 점도 알아 두자. 예를 들어 스택과 큐는 배열이나 연결 리스트로 구현할 수 있으며, 해시 테이블 구현에는 배열과 연결 리스트가 함께 포함될 수 있다. + +- **배열로 구현할 수 있는 것**: 스택, 큐, 해시 테이블, 트리, 힙, 그래프, 행렬, 텐서(차원이 $\geq 3$인 배열) 등. +- **연결 리스트로 구현할 수 있는 것**: 스택, 큐, 해시 테이블, 트리, 힙, 그래프 등. + +연결 리스트는 초기화한 뒤에도 프로그램 실행 중에 길이를 조정할 수 있으므로 ‘동적 자료 구조’라고도 한다. 배열은 초기화한 뒤 길이를 바꿀 수 없으므로 ‘정적 자료 구조’라고도 한다. 다만 배열도 메모리를 다시 할당하면 길이를 바꿀 수 있어 어느 정도 ‘동적 특성’을 가질 수 있다. + +!!! tip "참고" + + 물리적 구조를 이해하기 어렵다면 다음 장을 먼저 읽은 뒤 이 절을 복습하는 것을 권한다. diff --git a/ko/docs/chapter_data_structure/exercises.md b/ko/docs/chapter_data_structure/exercises.md new file mode 100644 index 000000000..e26626e00 --- /dev/null +++ b/ko/docs/chapter_data_structure/exercises.md @@ -0,0 +1,90 @@ +--- +comments: true +--- + +# 3.6   연습 문제 + +## 3.6.1   개념 확인 + +### 1.   일상 속 데이터의 관계 + +데이터 사이의 관계를 바탕으로 다음 세 상황에 대해 ‘선형 구조, 트리 구조, 네트워크 구조’ 중 하나를 고르고 이유를 설명하라. + + + +1. 학생들이 한 줄로 서 있고, 각자 바로 앞과 뒤에 있는 사람만 신경 쓴다. +2. 학교를 ‘학교 → 학년 → 학급’의 계층으로 나누어 관리한다. +3. 도시의 도로가 여러 교차로를 연결한다. 한 교차로에서 여러 다른 교차로로 갈 수 있으며 순환하는 경로도 생길 수 있다. + +??? success "참고 답안" + + 1. 선형 구조다. 맨 앞과 맨 뒤에 선 사람을 제외하면 각 사람은 앞뒤로 한 사람씩만 이웃하며, 관계가 하나의 선을 따라 이어진다. + + 2. 트리 구조다. 각 학급은 한 학년에 속하고 각 학년은 학교에 속하므로, 관계가 위에서 아래로 계층적으로 펼쳐진다. + + 3. 네트워크 구조다. 한 교차로가 여러 교차로와 연결될 수 있고 경로들이 순환을 이룰 수도 있으므로, 하나의 순서나 엄격한 계층으로 나열할 수 없다. + + 구조를 판단할 때는 메모리에서 차지하는 공간보다 원소 사이의 관계를 먼저 살펴보아야 한다. + +### 2.   논리적 순서를 메모리에 저장하는 방법 + +논리적 순서 `A → B → C`를 저장하기 위해 다음과 같이 단순화한 두 메모리 배치 방식을 생각해 보자. + +- 방법 가: `A, B, C`를 각각 번호가 `20, 21, 22`인 메모리 칸에 저장한다. +- 방법 나: `A, B, C`를 각각 번호가 `20, 7, 31`인 메모리 칸에 저장하고, `A`에 `B`의 위치를, `B`에 `C`의 위치를 기록한다. + + + +1. 어느 방법이 연속 공간 저장이고, 어느 방법이 분산 공간 저장인가? +2. 두 방법은 각각 배열과 연결 리스트 중 어느 쪽에 더 가까운가? +3. 방법 나는 메모리 칸의 번호가 크기순으로 정렬되어 있지 않은데, 어떻게 `A → B → C`의 논리적 순서를 나타낼 수 있는가? + +??? success "참고 답안" + + 1. 방법 가는 연속된 메모리 칸을 사용하므로 연속 공간 저장이다. 방법 나는 노드가 여러 위치에 흩어져 있으므로 분산 공간 저장이다. + + 2. 방법 가는 배열에, 방법 나는 연결 리스트에 더 가깝다. + + 3. 논리적 순서는 메모리 칸 번호의 크기가 아니라 노드 사이에 기록된 연결 관계로 결정된다. + `A`에 기록된 위치에서 `B`를 찾고, 다시 `B`에 기록된 위치에서 `C`를 찾을 수 있으므로 `A, B, C`에 차례대로 접근할 수 있다. + + 이는 논리적 구조와 물리적 구조가 같은 데이터 집합을 바라보는 서로 다른 두 관점임을 보여 준다. + +### 3.   과제 기록의 자료형과 구조 + +한 학습 모임에서 학생 4명의 과제 제출 여부를 좌석 순서대로 기록한 결과가 다음과 같다. + +`[true, false, true, true]` + + + +1. 각 원소에는 어떤 기본 자료형이 적합한가? +2. 이 4개 원소를 좌석 순서대로 한 줄에 나열한 것은 어떤 논리적 구조인가? +3. 나중에 각 학생의 과제 점수 `[90, 0, 85, 100]`를 기록하도록 바꾼다면, 달라지는 것은 데이터의 ‘내용 유형’인가, ‘조직 방식’인가? 이유를 설명하라. + +??? success "참고 답안" + + 1. 각 원소는 ‘예’ 또는 ‘아니요’만 나타내므로 불리언 자료형 `bool`이 적합하다. + + 2. 원소들이 좌석 순서대로 나열되어 선형 구조를 이루며, 배열에 저장할 수 있다. + + 3. 달라지는 것은 내용 유형이다. 원소가 불리언 값에서 정수로 바뀐다. 조직 방식은 바뀌지 않는다. + 데이터는 여전히 좌석 순서대로 한 줄에 나열되어 있으므로 배열이라는 선형 구조를 사용할 수 있다. + + 기본 자료형은 ‘무엇을 저장하는가’를, 자료 구조는 ‘데이터를 어떻게 구성하는가’를 설명한다. + +## 3.6.2   프로그래밍 연습 + +### 1.   이진수 표현에서 1의 개수 세기 + +음이 아닌 정수 `n`이 주어질 때, 이진수 표현에 포함된 1의 개수를 구하라. + +비트 연산을 사용해야 하며, 이진수 표현을 문자열로 변환하거나 1의 개수를 바로 세어 주는 내장 함수를 사용해서는 안 된다. + +??? tip "풀이 힌트" + + 1. n & 1로 n의 가장 오른쪽 비트를 얻어 그 비트가 1인지 확인할 수 있다. + 2. 오른쪽으로 한 비트 이동하면 현재 가장 오른쪽 비트가 제거된다. 대부분의 언어에서는 >> 연산자를 사용한다. + 3. 비트를 하나씩 확인하고 오른쪽으로 이동하는 방법을 완성한 뒤, n & (n - 1)이 n의 가장 오른쪽 1을 0으로 바꾼다는 점도 살펴보자. + +[LeetCode](https://leetcode.com/problems/number-of-1-bits/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_data_structure/index.md b/ko/docs/chapter_data_structure/index.md new file mode 100644 index 000000000..4e7d9d8cf --- /dev/null +++ b/ko/docs/chapter_data_structure/index.md @@ -0,0 +1,23 @@ +--- +comments: true +icon: material/shape-outline +--- + +# 제 3 장   자료 구조 + +![자료 구조](../assets/covers/chapter_data_structure.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 자료 구조는 튼튼하면서도 다양한 형태를 지닌 뼈대와 같다. + + 데이터를 질서 있게 조직하는 설계도를 제공하며, 알고리즘은 그 토대 위에서 생동감을 얻는다. + +## 이 장의 내용 + +- [3.1   자료 구조의 분류](classification_of_data_structure.md) +- [3.2   기본 자료형](basic_data_types.md) +- [3.3   수의 인코딩 *](number_encoding.md) +- [3.4   문자 인코딩 *](character_encoding.md) +- [3.5   요약](summary.md) +- [3.6   연습 문제](exercises.md) diff --git a/ko/docs/chapter_data_structure/number_encoding.md b/ko/docs/chapter_data_structure/number_encoding.md new file mode 100644 index 000000000..bfd6ef497 --- /dev/null +++ b/ko/docs/chapter_data_structure/number_encoding.md @@ -0,0 +1,162 @@ +--- +comments: true +--- + +# 3.3   수의 인코딩 * + +!!! tip "참고" + + 이 책에서 제목에 * 기호가 있는 절은 선택 학습 내용이다. 시간이 부족하거나 이해하기 어렵다면 먼저 건너뛰고, 필수 내용을 학습한 뒤 따로 도전해도 좋다. + +## 3.3.1   부호와 크기, 1의 보수, 2의 보수 + +앞 절의 표에서 모든 정수형은 표현할 수 있는 음수가 양수보다 하나 더 많았다. 예를 들어 `byte`의 값 범위는 $[-128, 127]$이다. 직관과 어긋나는 이 현상의 이유를 이해하려면 부호와 크기, 1의 보수, 2의 보수에 대해 알아야 한다. + +먼저 **수는 컴퓨터에 ‘2의 보수’ 형태로 저장된다**는 점을 알아 두자. 그 이유를 분석하기 전에 세 가지 표현을 정의하겠다. + +- **부호와 크기**: 이진수 표현의 최상위 비트를 부호 비트로 사용한다. $0$은 양수, $1$은 음수를 나타내며 나머지 비트는 수의 크기를 나타낸다. +- **1의 보수**: 양수의 1의 보수는 부호와 크기 표현과 같다. 음수의 1의 보수는 부호와 크기 표현에서 부호 비트를 제외한 모든 비트를 반전한 것이다. +- **2의 보수**: 양수의 2의 보수는 부호와 크기 표현과 같다. 음수의 2의 보수는 1의 보수에 $1$을 더한 것이다. + +아래 그림(3-4)은 부호와 크기, 1의 보수, 2의 보수를 서로 변환하는 방법을 보여 준다. + +![부호와 크기, 1의 보수, 2의 보수의 상호 변환](number_encoding.assets/1s_2s_complement.png){ class="animation-figure" } + +

그림 3-4   부호와 크기, 1의 보수, 2의 보수의 상호 변환

+ +부호와 크기(sign-magnitude) 표현은 가장 직관적이지만 한계가 있다. 우선 **음수의 부호와 크기 표현은 연산에 그대로 사용할 수 없다**. 예를 들어 이 표현으로 $1 + (-2)$를 계산하면 결과가 $-3$이 되어 명백히 틀린다. + +$$ +\begin{aligned} +& 1 + (-2) \newline +& \rightarrow 0000 \; 0001 + 1000 \; 0010 \newline +& = 1000 \; 0011 \newline +& \rightarrow -3 +\end{aligned} +$$ + +이 문제를 해결하기 위해 1의 보수(1's complement)가 도입되었다. 부호와 크기 표현을 먼저 1의 보수로 바꾸고 $1 + (-2)$를 계산한 뒤, 결과를 다시 부호와 크기 표현으로 바꾸면 올바른 결과인 $-1$을 얻는다. + +$$ +\begin{aligned} +& 1 + (-2) \newline +& \rightarrow 0000 \; 0001 \; \text{(부호와 크기)} + 1000 \; 0010 \; \text{(부호와 크기)} \newline +& = 0000 \; 0001 \; \text{(1의 보수)} + 1111 \; 1101 \; \text{(1의 보수)} \newline +& = 1111 \; 1110 \; \text{(1의 보수)} \newline +& = 1000 \; 0001 \; \text{(부호와 크기)} \newline +& \rightarrow -1 +\end{aligned} +$$ + +또한 **부호와 크기 표현에서는 0을 $+0$과 $-0$의 두 가지 방식으로 나타낸다**. 즉, 0에 서로 다른 두 이진 인코딩이 대응하므로 모호함이 생길 수 있다. 예를 들어 조건을 판단할 때 양의 0과 음의 0을 구분하지 않으면 결과가 틀릴 수 있다. 이러한 모호함을 처리하려면 추가 판단 연산이 필요하므로 컴퓨터의 연산 효율이 낮아질 수 있다. + +$$ +\begin{aligned} ++0 & \rightarrow 0000 \; 0000 \newline +-0 & \rightarrow 1000 \; 0000 +\end{aligned} +$$ + +1의 보수에도 부호와 크기 표현처럼 양의 0과 음의 0의 모호함이 있다. 그래서 2의 보수(2's complement)가 도입되었다. 먼저 음의 0을 부호와 크기, 1의 보수, 2의 보수로 변환하는 과정을 살펴보자. + +$$ +\begin{aligned} +-0 \rightarrow \; & 1000 \; 0000 \; \text{(부호와 크기)} \newline += \; & 1111 \; 1111 \; \text{(1의 보수)} \newline += 1 \; & 0000 \; 0000 \; \text{(2의 보수)} \newline +\end{aligned} +$$ + +음의 0의 1의 보수에 $1$을 더하면 자리 올림이 발생한다. 그러나 `byte`는 길이가 8비트뿐이므로 9번째 비트로 넘친 $1$은 버려진다. 즉, **음의 0의 2의 보수는 $0000 \; 0000$으로, 양의 0의 2의 보수와 같다**. 따라서 2의 보수 표현에는 0이 하나만 존재하며, 양의 0과 음의 0의 모호함이 해결된다. + +마지막 의문이 남는다. `byte`의 값 범위는 $[-128, 127]$인데, 하나 더 있는 음수 $-128$은 어떻게 얻을까? 구간 $[-127, +127]$의 모든 정수에는 대응하는 부호와 크기, 1의 보수, 2의 보수 표현이 있으며, 부호와 크기 표현과 2의 보수는 서로 변환할 수 있다. + +그러나 **2의 보수 $1000 \; 0000$은 예외로, 대응하는 부호와 크기 표현이 없다**. 변환 방법을 적용하면 부호와 크기 표현으로 $0000 \; 0000$을 얻는다. 하지만 이 표현은 수 $0$을 나타내며 2의 보수도 자기 자신이어야 하므로 모순이다. 컴퓨터에서는 이 특수한 2의 보수 $1000 \; 0000$이 $-128$을 나타내도록 정한다. 실제로 $(-1) + (-127)$을 2의 보수로 계산한 결과는 $-128$이다. + +$$ +\begin{aligned} +& (-127) + (-1) \newline +& \rightarrow 1111 \; 1111 \; \text{(부호와 크기)} + 1000 \; 0001 \; \text{(부호와 크기)} \newline +& = 1000 \; 0000 \; \text{(1의 보수)} + 1111 \; 1110 \; \text{(1의 보수)} \newline +& = 1000 \; 0001 \; \text{(2의 보수)} + 1111 \; 1111 \; \text{(2의 보수)} \newline +& = 1000 \; 0000 \; \text{(2의 보수)} \newline +& \rightarrow -128 +\end{aligned} +$$ + +위의 계산이 모두 덧셈이라는 사실을 발견했을 것이다. 여기에는 중요한 사실이 담겨 있다. **컴퓨터 내부의 하드웨어 회로는 주로 덧셈 연산을 기반으로 설계된다**. 덧셈은 곱셈, 나눗셈, 뺄셈 같은 다른 연산보다 하드웨어로 구현하기 쉽고, 병렬 처리도 쉬우며, 연산 속도도 빠르기 때문이다. + +그렇다고 컴퓨터가 덧셈만 할 수 있다는 뜻은 아니다. **덧셈과 몇 가지 기본 논리 연산을 결합하면 다양한 수학 연산을 구현할 수 있다**. 예를 들어 뺄셈 $a - b$는 덧셈 $a + (-b)$로 바꿀 수 있고, 곱셈과 나눗셈도 여러 번의 덧셈이나 뺄셈으로 바꿀 수 있다. + +이제 컴퓨터가 2의 보수를 사용하는 이유를 정리할 수 있다. 2의 보수 표현을 쓰면 같은 회로와 연산으로 양수와 음수의 덧셈을 처리할 수 있다. 뺄셈을 위한 별도 하드웨어 회로를 설계할 필요가 없으며, 양의 0과 음의 0의 모호함도 따로 처리하지 않아도 된다. 이는 하드웨어 설계를 크게 단순화하고 연산 효율을 높인다. + +2의 보수는 매우 정교하게 설계된 방식이지만, 분량상 여기까지만 소개한다. 관심 있는 독자는 더 깊이 알아보기를 권한다. + +## 3.3.2   부동 소수점 인코딩 + +자세히 살펴보면 이런 의문이 생긴다. `int`와 `float`는 모두 4바이트로 길이가 같은데, 왜 `float`의 값 범위가 `int`보다 훨씬 넓을까? `float`는 소수도 표현해야 하므로 오히려 값 범위가 줄어들 것 같아 직관과 어긋난다. + +실제로 **이는 부동 소수점형 `float`가 다른 표현 방식을 사용하기 때문이다**. 길이가 32비트인 이진수를 다음과 같이 쓰자. + +$$ +b_{31} b_{30} b_{29} \ldots b_2 b_1 b_0 +$$ + +IEEE 754 표준에 따르면 32비트 `float`는 다음 세 부분으로 이루어진다. + +- 부호 비트 $\mathrm{S}$: 1비트를 차지하며 $b_{31}$에 해당한다. +- 지수 비트 $\mathrm{E}$: 8비트를 차지하며 $b_{30} b_{29} \ldots b_{23}$에 해당한다. +- 가수의 소수부 비트 $\mathrm{N}$: 23비트를 차지하며 $b_{22} b_{21} \ldots b_0$에 해당한다. + +이진수 `float`가 나타내는 값을 계산하는 방법은 다음과 같다. + +$$ +\text {val} = (-1)^{b_{31}} \times 2^{\left(b_{30} b_{29} \ldots b_{23}\right)_2-127} \times\left(1 . b_{22} b_{21} \ldots b_0\right)_2 +$$ + +이를 십진수로 바꾸면 다음 식을 얻는다. + +$$ +\text {val}=(-1)^{\mathrm{S}} \times 2^{\mathrm{E} -127} \times (1 + \mathrm{N}) +$$ + +각 항의 값 범위는 다음과 같다. + +$$ +\begin{aligned} +\mathrm{S} \in & \{ 0, 1\}, \quad \mathrm{E} \in \{ 1, 2, \dots, 254 \} \newline +(1 + \mathrm{N}) = & (1 + \sum_{i=1}^{23} b_{23-i} 2^{-i}) \subset [1, 2 - 2^{-23}] +\end{aligned} +$$ + +![IEEE 754 표준에 따른 float 계산 예](number_encoding.assets/ieee_754_float.png){ class="animation-figure" } + +

그림 3-5   IEEE 754 표준에 따른 float 계산 예

+ +위 그림(3-5)에서 예로 든 데이터가 $\mathrm{S} = 0$, $\mathrm{E} = 124$, $\mathrm{N} = 2^{-2} + 2^{-3} = 0.375$이면 다음과 같다. + +$$ +\text { val } = (-1)^0 \times 2^{124 - 127} \times (1 + 0.375) = 0.171875 +$$ + +이제 처음의 질문에 답할 수 있다. **`float`의 표현 방식에는 지수 비트가 있으므로 값 범위가 `int`보다 훨씬 넓다**. 위 계산에 따르면 `float`로 표현할 수 있는 가장 큰 양수는 $2^{254 - 127} \times (2 - 2^{-23}) \approx 3.4 \times 10^{38}$이며, 부호 비트를 바꾸면 가장 작은 음수를 얻는다. + +**부동 소수점형 `float`는 값 범위를 넓히는 대신 정밀도를 희생한다**. 정수형 `int`는 32비트 전체로 수를 표현하므로 수들이 균일하게 분포한다. 반면 부동 소수점형 `float`는 지수 비트 때문에 값이 커질수록 인접한 두 수 사이의 간격도 커지는 경향이 있다. + +아래 표(3-2)와 같이 지수 비트 $\mathrm{E} = 0$과 $\mathrm{E} = 255$는 특별한 의미를 가지며, **0, 무한대, $\mathrm{NaN}$ 등을 표현하는 데 사용된다**. + +

표 3-2   지수 비트의 의미

+ +
+ +| 지수 비트 E | 소수부 $\mathrm{N} = 0$ | 소수부 $\mathrm{N} \ne 0$ | 계산식 | +| ------------------ | ----------------------- | ------------------------- | ---------------------------------------------------------------------- | +| $0$ | $\pm 0$ | 비정규화 수 | $(-1)^{\mathrm{S}} \times 2^{-126} \times (0.\mathrm{N})$ | +| $1, 2, \dots, 254$ | 정규화 수 | 정규화 수 | $(-1)^{\mathrm{S}} \times 2^{(\mathrm{E} -127)} \times (1.\mathrm{N})$ | +| $255$ | $\pm \infty$ | $\mathrm{NaN}$ | | + +
+ +비정규화 수는 부동 소수점의 정밀도를 크게 높인다는 점도 알아 두자. 가장 작은 양의 정규화 수는 $2^{-126}$이고, 가장 작은 양의 비정규화 수는 $2^{-126} \times 2^{-23}$이다. + +배정밀도 `double`도 `float`와 비슷한 표현 방식을 사용하므로 여기서는 자세한 설명을 생략한다. diff --git a/ko/docs/chapter_data_structure/summary.md b/ko/docs/chapter_data_structure/summary.md new file mode 100644 index 000000000..bbef40a89 --- /dev/null +++ b/ko/docs/chapter_data_structure/summary.md @@ -0,0 +1,70 @@ +--- +comments: true +--- + +# 3.5   요약 + +### 1.   핵심 정리 + +- 자료 구조는 논리적 구조와 물리적 구조라는 두 관점에서 분류할 수 있다. 논리적 구조는 데이터 원소 사이의 논리적 관계를 설명하고, 물리적 구조는 컴퓨터 메모리에 데이터를 저장하는 방식을 설명한다. +- 대표적인 논리적 구조에는 선형, 트리형, 네트워크형 등이 있다. 보통 논리적 구조에 따라 자료 구조를 선형(배열, 연결 리스트, 스택, 큐)과 비선형(트리, 그래프, 힙)으로 나눈다. 해시 테이블 구현에는 선형과 비선형 자료 구조가 함께 포함될 수 있다. +- 프로그램이 실행되면 데이터는 컴퓨터 메모리에 저장된다. 각 메모리 공간에는 대응하는 메모리 주소가 있으며, 프로그램은 이 주소로 데이터에 접근한다. +- 물리적 구조는 주로 연속 공간 저장(배열)과 분산 공간 저장(연결 리스트)으로 나뉜다. 모든 자료 구조는 배열, 연결 리스트 또는 둘의 조합으로 구현된다. +- 컴퓨터의 기본 자료형에는 정수형 `byte`, `short`, `int`, `long`, 부동 소수점형 `float`, `double`, 문자형 `char`, 불리언형 `bool`이 있다. 값 범위는 차지하는 공간의 크기와 표현 방식에 따라 결정된다. +- 부호와 크기, 1의 보수, 2의 보수는 컴퓨터에서 수를 인코딩하는 세 가지 방식으로, 서로 변환할 수 있다. 정수의 부호와 크기 표현에서 최상위 비트는 부호 비트이고, 나머지 비트는 수의 크기를 나타낸다. +- 정수는 컴퓨터에 2의 보수 형태로 저장된다. 이 표현에서는 양수와 음수의 덧셈을 똑같이 처리할 수 있어 뺄셈만을 위한 별도 하드웨어 회로가 필요 없으며, 양의 0과 음의 0의 모호함도 없다. +- 부동 소수점 인코딩은 부호 1비트, 지수 8비트, 가수의 소수부 23비트로 이루어진다. 지수 비트 덕분에 정수보다 훨씬 넓은 값 범위를 갖는 대신 정밀도를 희생한다. +- ASCII 코드는 가장 초기에 등장한 영어 문자 집합으로, 길이가 1바이트이며 128개의 문자를 수록한다. GBK는 널리 쓰이는 중국어 문자 집합으로 2만 자 이상의 한자를 수록한다. Unicode는 전 세계 여러 언어의 문자를 담은 완전한 문자 집합 표준을 제공하여 인코딩 방식의 차이로 발생하는 글자 깨짐을 해결하고자 한다. +- UTF-8은 가장 널리 쓰이는 Unicode 인코딩 방식으로 범용성이 매우 좋다. 확장성이 뛰어난 가변 길이 인코딩이며 저장 공간을 효율적으로 활용한다. UTF-16과 UTF-32는 고정 길이 인코딩 방식이다. 중국어를 인코딩할 때 UTF-16은 UTF-8보다 공간을 적게 차지한다. Java, C# 등의 언어는 기본적으로 UTF-16을 사용한다. + +### 2.   Q & A + +**Q**: 해시 테이블에는 왜 선형 자료 구조와 비선형 자료 구조가 함께 포함되는가? + +해시 테이블의 기반은 배열이며, 해시 충돌을 해결하기 위해 ‘체이닝’을 사용할 수 있다(뒤의 ‘해시 충돌’ 절에서 다룬다). 배열의 각 버킷은 연결 리스트를 가리키며, 연결 리스트 길이가 일정 기준을 넘으면 트리(보통 레드-블랙 트리)로 바뀔 수 있다. + +저장 관점에서 해시 테이블의 기반은 배열이고, 각 버킷의 슬롯에는 값 하나, 연결 리스트 또는 트리가 들어갈 수 있다. 따라서 해시 테이블에는 선형 자료 구조(배열, 연결 리스트)와 비선형 자료 구조(트리)가 함께 포함될 수 있다. + +**Q**: `char` 자료형의 길이는 1바이트인가? + +`char`의 길이는 프로그래밍 언어가 사용하는 인코딩 방식에 따라 정해진다. 예를 들어 Java, JavaScript, TypeScript, C#은 모두 UTF-16 인코딩으로 Unicode 코드 포인트를 저장하므로 `char`의 길이는 2바이트다. + +**Q**: 배열로 구현한 자료 구조를 ‘정적 자료 구조’라고 부르면 모호하지 않은가? 스택에서도 원소를 꺼내거나 넣을 수 있는데, 이런 연산은 모두 ‘동적’이다. + +스택은 동적으로 데이터를 조작할 수 있지만 자료 구조는 여전히 ‘정적’이다. 즉, 길이를 바꿀 수 없다. 배열 기반 자료 구조는 원소를 동적으로 추가하거나 삭제할 수 있지만 용량은 고정되어 있다. 데이터 양이 미리 할당한 크기를 넘으면 더 큰 배열을 새로 만들고 기존 내용을 복사해야 한다. + +**Q**: 스택이나 큐를 만들 때 크기를 지정하지 않았는데, 왜 ‘정적 자료 구조’인가? + +고급 프로그래밍 언어에서는 스택이나 큐의 초기 용량을 직접 지정하지 않아도 클래스 내부에서 자동으로 처리한다. 예를 들어 Java `ArrayList`의 초기 용량은 보통 10이다. 용량 확장도 자동으로 이루어진다. 자세한 내용은 뒤의 ‘리스트’ 절에서 설명한다. + +**Q**: 부호와 크기 표현을 2의 보수로 바꾸려면 ‘반전한 뒤 1을 더한다’. 그렇다면 반대 변환은 역연산인 ‘1을 뺀 뒤 반전하기’여야 할 텐데, ‘반전한 뒤 1을 더하기’로도 가능한 이유는 무엇인가? + +두 표현의 상호 변환은 실제로 ‘보수’를 구하는 과정이기 때문이다. 먼저 보수를 정의하자. $a + b = c$라면 $a$를 $c$에 대한 $b$의 보수라 하고, 반대로 $b$도 $c$에 대한 $a$의 보수라고 한다. + +$n = 4$비트 이진수 $0010$이 주어졌다고 하자. 이를 부호와 크기 표현으로 보면(부호 비트는 고려하지 않는다), 2의 보수는 ‘반전한 뒤 1을 더해’ 얻는다. + +$$ +0010 \rightarrow 1101 \rightarrow 1110 +$$ + +원래 표현과 2의 보수의 합은 $0010 + 1110 = 10000$이다. 즉, 2의 보수 $1110$은 $10000$에 대한 원래 수 $0010$의 ‘보수’다. **따라서 ‘반전한 뒤 1을 더하기’는 실제로 $10000$에 대한 보수를 계산하는 과정이다**. + +그렇다면 $10000$에 대한 2의 보수 $1110$의 ‘보수’는 무엇일까? 똑같이 ‘반전한 뒤 1을 더해서’ 얻을 수 있다. + +$$ +1110 \rightarrow 0001 \rightarrow 0010 +$$ + +즉, 원래 표현과 2의 보수는 $10000$에 대해 서로의 ‘보수’다. 따라서 두 방향의 변환에 같은 연산인 ‘반전한 뒤 1을 더하기’를 사용할 수 있다. + +물론 역연산인 ‘1을 뺀 뒤 반전하기’로도 2의 보수 $1110$에서 원래 표현을 구할 수 있다. + +$$ +1110 \rightarrow 1101 \rightarrow 0010 +$$ + +정리하면 ‘반전한 뒤 1을 더하기’와 ‘1을 뺀 뒤 반전하기’는 모두 $10000$에 대한 보수를 구하는 연산이므로 서로 동등하다. + +본질적으로 ‘반전’은 $1111$에 대한 보수를 구하는 연산이다(항상 `부호와 크기 표현 + 1의 보수 = 1111`이 성립하기 때문이다). 여기에 1을 더해서 얻은 2의 보수는 $10000$에 대한 보수다. + +위 설명은 $n = 4$를 예로 들었지만, 자릿수가 임의의 값인 이진수에도 일반화할 수 있다. diff --git a/ko/docs/chapter_divide_and_conquer/binary_search_recur.md b/ko/docs/chapter_divide_and_conquer/binary_search_recur.md new file mode 100644 index 000000000..8cdaea613 --- /dev/null +++ b/ko/docs/chapter_divide_and_conquer/binary_search_recur.md @@ -0,0 +1,456 @@ +--- +comments: true +--- + +# 12.2   분할 정복 탐색 전략 + +앞에서 배웠듯이 탐색 알고리즘은 크게 두 종류로 나뉜다. + +- **완전 탐색**: 자료 구조를 순회하며, 시간 복잡도는 $O(n)$이다. +- **적응형 탐색**: 데이터 고유의 구성 방식이나 사전 정보를 이용하며, 시간 복잡도가 $O(\log n)$ 또는 $O(1)$에 이를 수 있다. + +실제로 **시간 복잡도가 $O(\log n)$인 탐색 알고리즘은 보통 분할 정복 전략을 기반으로 구현된다**. 이진 탐색과 트리가 그 예다. + +- 이진 탐색은 각 단계에서 원래 문제(배열에서 목표 원소 찾기)를 더 작은 문제(배열의 절반에서 목표 원소 찾기)로 나눈다. 배열이 비거나 목표 원소를 찾을 때까지 계속한다. +- 트리는 분할 정복 사고의 대표적인 예다. 이진 탐색 트리, AVL 트리, 힙 등의 자료 구조에서 여러 연산의 시간 복잡도는 $O(\log n)$이다. + +이진 탐색의 분할 정복 전략은 다음과 같다. + +- **문제를 나눌 수 있다**: 중간 원소와 목표 원소를 비교하여 원래 문제(배열에서 찾기)를 부분 문제(배열의 절반에서 찾기)로 재귀적으로 나눈다. +- **부분 문제가 독립적이다**: 이진 탐색은 매번 부분 문제 하나만 처리하며 다른 부분 문제의 영향을 받지 않는다. +- **부분 문제의 해를 합칠 필요가 없다**: 특정 원소 하나를 찾는 것이 목적이므로 부분 문제의 해를 합칠 필요가 없다. 부분 문제를 해결하면 원래 문제도 함께 해결된다. + +분할 정복이 탐색 효율을 높이는 근본적인 이유는 완전 탐색이 매번 선택지 하나만 제외하는 데 비해 **분할 정복 탐색은 매번 선택지의 절반을 제외할 수 있기 때문**이다. + +### 1.   분할 정복으로 이진 탐색 구현 + +앞 장에서는 이진 탐색을 반복 방식으로 구현했다. 이제 분할 정복(재귀)으로 구현해 보자. + +!!! question "문제" + + 길이가 $n$이고 모든 원소가 서로 다른 정렬된 배열 `nums`가 주어진다. 원소 `target`을 찾아라. + +분할 정복의 관점에서 탐색 구간 $[i, j]$에 해당하는 부분 문제를 $f(i, j)$라고 하자. + +원래 문제 $f(0, n-1)$에서 시작하여 다음 단계로 이진 탐색을 수행한다. + +1. 탐색 구간 $[i, j]$의 중간 인덱스 $m$을 계산하고, 이를 기준으로 탐색 구간의 절반을 제외한다. +2. 크기가 절반으로 줄어든 부분 문제 $f(i, m-1)$ 또는 $f(m+1, j)$를 재귀적으로 푼다. +3. `target`을 찾거나 구간이 빌 때까지 `1.` 단계와 `2.` 단계를 반복한 뒤 반환한다. + +아래 그림(12-4)은 배열에서 원소 $6$을 이진 탐색하는 분할 정복 과정을 보여 준다. + +![이진 탐색의 분할 정복 과정](binary_search_recur.assets/binary_search_recur.png){ class="animation-figure" } + +

그림 12-4   이진 탐색의 분할 정복 과정

+ +구현 코드에서는 문제 $f(i, j)$를 푸는 재귀 함수 `dfs()`를 정의한다. + +=== "Python" + + ```python title="binary_search_recur.py" + def dfs(nums: list[int], target: int, i: int, j: int) -> int: + """이진 탐색: 문제 f(i, j)""" + # 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if i > j: + return -1 + # 중간 인덱스 m을 계산한다 + m = (i + j) // 2 + if nums[m] < target: + # 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m + 1, j) + elif nums[m] > target: + # 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m - 1) + else: + # 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + + def binary_search(nums: list[int], target: int) -> int: + """이진 탐색""" + n = len(nums) + # 문제 f(0, n-1)을 푼다 + return dfs(nums, target, 0, n - 1) + ``` + +=== "C++" + + ```cpp title="binary_search_recur.cpp" + /* 이진 탐색: 문제 f(i, j) */ + int dfs(vector &nums, int target, int i, int j) { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if (i > j) { + return -1; + } + // 중간 인덱스 m을 계산한다 + int m = (i + j) / 2; + if (nums[m] < target) { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m + 1, j); + } else if (nums[m] > target) { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m - 1); + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + + /* 이진 탐색 */ + int binarySearch(vector &nums, int target) { + int n = nums.size(); + // 문제 f(0, n-1)을 푼다 + return dfs(nums, target, 0, n - 1); + } + ``` + +=== "Java" + + ```java title="binary_search_recur.java" + /* 이진 탐색: 문제 f(i, j) */ + int dfs(int[] nums, int target, int i, int j) { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if (i > j) { + return -1; + } + // 중간 인덱스 m을 계산한다 + int m = (i + j) / 2; + if (nums[m] < target) { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m + 1, j); + } else if (nums[m] > target) { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m - 1); + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + + /* 이진 탐색 */ + int binarySearch(int[] nums, int target) { + int n = nums.length; + // 문제 f(0, n-1)을 푼다 + return dfs(nums, target, 0, n - 1); + } + ``` + +=== "C#" + + ```csharp title="binary_search_recur.cs" + /* 이진 탐색: 문제 f(i, j) */ + int DFS(int[] nums, int target, int i, int j) { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if (i > j) { + return -1; + } + // 중간 인덱스 m을 계산한다 + int m = (i + j) / 2; + if (nums[m] < target) { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return DFS(nums, target, m + 1, j); + } else if (nums[m] > target) { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return DFS(nums, target, i, m - 1); + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + + /* 이진 탐색 */ + int BinarySearch(int[] nums, int target) { + int n = nums.Length; + // 문제 f(0, n-1)을 푼다 + return DFS(nums, target, 0, n - 1); + } + ``` + +=== "Go" + + ```go title="binary_search_recur.go" + /* 이진 탐색: 문제 f(i, j) */ + func dfs(nums []int, target, i, j int) int { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if i > j { + return -1 + } + // 중간 인덱스를 계산한다 + m := i + ((j - i) >> 1) + //중간 원소와 목표 원소의 크기를 비교한다 + if nums[m] < target { + // 중간 원소가 더 작으면 배열의 오른쪽 절반을 재귀적으로 탐색한다 + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m+1, j) + } else if nums[m] > target { + // 중간 원소가 더 크면 배열의 왼쪽 절반을 재귀적으로 탐색한다 + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m-1) + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + } + } + + /* 이진 탐색 */ + func binarySearch(nums []int, target int) int { + n := len(nums) + return dfs(nums, target, 0, n-1) + } + ``` + +=== "Swift" + + ```swift title="binary_search_recur.swift" + /* 이진 탐색: 문제 f(i, j) */ + func dfs(nums: [Int], target: Int, i: Int, j: Int) -> Int { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if i > j { + return -1 + } + // 중간 인덱스 m을 계산한다 + let m = (i + j) / 2 + if nums[m] < target { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums: nums, target: target, i: m + 1, j: j) + } else if nums[m] > target { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums: nums, target: target, i: i, j: m - 1) + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + } + } + + /* 이진 탐색 */ + func binarySearch(nums: [Int], target: Int) -> Int { + // 문제 f(0, n-1)을 푼다 + dfs(nums: nums, target: target, i: nums.startIndex, j: nums.endIndex - 1) + } + ``` + +=== "JS" + + ```javascript title="binary_search_recur.js" + /* 이진 탐색: 문제 f(i, j) */ + function dfs(nums, target, i, j) { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if (i > j) { + return -1; + } + // 중간 인덱스 m을 계산한다 + const m = i + ((j - i) >> 1); + if (nums[m] < target) { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m + 1, j); + } else if (nums[m] > target) { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m - 1); + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + + /* 이진 탐색 */ + function binarySearch(nums, target) { + const n = nums.length; + // 문제 f(0, n-1)을 푼다 + return dfs(nums, target, 0, n - 1); + } + ``` + +=== "TS" + + ```typescript title="binary_search_recur.ts" + /* 이진 탐색: 문제 f(i, j) */ + function dfs(nums: number[], target: number, i: number, j: number): number { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if (i > j) { + return -1; + } + // 중간 인덱스 m을 계산한다 + const m = i + ((j - i) >> 1); + if (nums[m] < target) { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m + 1, j); + } else if (nums[m] > target) { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m - 1); + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + + /* 이진 탐색 */ + function binarySearch(nums: number[], target: number): number { + const n = nums.length; + // 문제 f(0, n-1)을 푼다 + return dfs(nums, target, 0, n - 1); + } + ``` + +=== "Dart" + + ```dart title="binary_search_recur.dart" + /* 이진 탐색: 문제 f(i, j) */ + int dfs(List nums, int target, int i, int j) { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if (i > j) { + return -1; + } + // 중간 인덱스 m을 계산한다 + int m = (i + j) ~/ 2; + if (nums[m] < target) { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m + 1, j); + } else if (nums[m] > target) { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m - 1); + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + + /* 이진 탐색 */ + int binarySearch(List nums, int target) { + int n = nums.length; + // 문제 f(0, n-1)을 푼다 + return dfs(nums, target, 0, n - 1); + } + ``` + +=== "Rust" + + ```rust title="binary_search_recur.rs" + /* 이진 탐색: 문제 f(i, j) */ + fn dfs(nums: &[i32], target: i32, i: i32, j: i32) -> i32 { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if i > j { + return -1; + } + let m: i32 = i + (j - i) / 2; + if nums[m as usize] < target { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m + 1, j); + } else if nums[m as usize] > target { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m - 1); + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + + /* 이진 탐색 */ + fn binary_search(nums: &[i32], target: i32) -> i32 { + let n = nums.len() as i32; + // 문제 f(0, n-1)을 푼다 + dfs(nums, target, 0, n - 1) + } + ``` + +=== "C" + + ```c title="binary_search_recur.c" + /* 이진 탐색: 문제 f(i, j) */ + int dfs(int nums[], int target, int i, int j) { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if (i > j) { + return -1; + } + // 중간 인덱스 m을 계산한다 + int m = (i + j) / 2; + if (nums[m] < target) { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m + 1, j); + } else if (nums[m] > target) { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m - 1); + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + + /* 이진 탐색 */ + int binarySearch(int nums[], int target, int numsSize) { + int n = numsSize; + // 문제 f(0, n-1)을 푼다 + return dfs(nums, target, 0, n - 1); + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search_recur.kt" + /* 이진 탐색: 문제 f(i, j) */ + fun dfs( + nums: IntArray, + target: Int, + i: Int, + j: Int + ): Int { + // 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + if (i > j) { + return -1 + } + // 중간 인덱스 m을 계산한다 + val m = (i + j) / 2 + return if (nums[m] < target) { + // 부분 문제 f(m+1, j)를 재귀적으로 푼다 + dfs(nums, target, m + 1, j) + } else if (nums[m] > target) { + // 부분 문제 f(i, m-1)를 재귀적으로 푼다 + dfs(nums, target, i, m - 1) + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + m + } + } + + /* 이진 탐색 */ + fun binarySearch(nums: IntArray, target: Int): Int { + val n = nums.size + // 문제 f(0, n-1)을 푼다 + return dfs(nums, target, 0, n - 1) + } + ``` + +=== "Ruby" + + ```ruby title="binary_search_recur.rb" + ### 이진 탐색: 문제 f(i, j) ### + def dfs(nums, target, i, j) + # 구간이 비어 있으면 목표 원소가 없다는 뜻이므로 -1을 반환한다 + return -1 if i > j + + # 중간 인덱스 m을 계산한다 + m = (i + j) / 2 + + if nums[m] < target + # 부분 문제 f(m+1, j)를 재귀적으로 푼다 + return dfs(nums, target, m + 1, j) + elsif nums[m] > target + # 부분 문제 f(i, m-1)를 재귀적으로 푼다 + return dfs(nums, target, i, m - 1) + else + # 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + end + end + + ### 이진 탐색 ### + def binary_search(nums, target) + n = nums.length + # 문제 f(0, n-1)을 푼다 + dfs(nums, target, 0, n - 1) + end + ``` + +??? pythontutor "코드 시각화" + +
+ diff --git a/ko/docs/chapter_divide_and_conquer/build_binary_tree_problem.md b/ko/docs/chapter_divide_and_conquer/build_binary_tree_problem.md new file mode 100644 index 000000000..b633508fe --- /dev/null +++ b/ko/docs/chapter_divide_and_conquer/build_binary_tree_problem.md @@ -0,0 +1,559 @@ +--- +comments: true +--- + +# 12.3   이진 트리 구성 문제 + +!!! question "문제" + + 이진 트리의 전위 순회 `preorder`와 중위 순회 `inorder`가 주어진다. 이를 이용해 이진 트리를 구성하고 루트 노드를 반환하라. 트리에는 값이 중복되는 노드가 없다고 가정한다(아래 그림(12-5) 참고). + +![이진 트리 구성 예시 데이터](build_binary_tree_problem.assets/build_tree_example.png){ class="animation-figure" } + +

그림 12-5   이진 트리 구성 예시 데이터

+ +### 1.   분할 정복 문제인지 판단 + +원래 문제는 `preorder`와 `inorder`로 이진 트리를 구성하는 것으로, 전형적인 분할 정복 문제다. + +- **문제를 나눌 수 있다**: 원래 문제를 왼쪽 서브트리 구성과 오른쪽 서브트리 구성이라는 두 부분 문제로 나누고, 루트 노드 초기화 연산을 하나 더할 수 있다. 각 서브트리(부분 문제)에도 같은 방법을 적용해 더 작은 서브트리(부분 문제)로 나눌 수 있으며, 가장 작은 부분 문제인 빈 서브트리에 도달하면 종료한다. +- **부분 문제가 독립적이다**: 왼쪽 서브트리와 오른쪽 서브트리는 겹치는 부분 없이 서로 독립적이다. 왼쪽 서브트리를 구성할 때는 중위 순회와 전위 순회에서 왼쪽 서브트리에 해당하는 부분만 보면 된다. 오른쪽도 마찬가지다. +- **부분 문제의 해를 합칠 수 있다**: 왼쪽 서브트리와 오른쪽 서브트리(부분 문제의 해)를 얻으면 루트 노드에 연결하여 원래 문제의 해를 만들 수 있다. + +### 2.   서브트리를 나누는 방법 + +위 분석에 따르면 분할 정복으로 해결할 수 있다. **그런데 전위 순회 `preorder`와 중위 순회 `inorder`로 왼쪽과 오른쪽 서브트리를 어떻게 나눌까**? + +정의에 따르면 `preorder`와 `inorder`는 모두 세 부분으로 나뉜다. + +- 전위 순회: `[ 루트 노드 | 왼쪽 서브트리 | 오른쪽 서브트리 ]`. 예를 들어 위 그림(12-5)의 트리는 `[ 3 | 9 | 2 1 7 ]`에 해당한다. +- 중위 순회: `[ 왼쪽 서브트리 | 루트 노드 | 오른쪽 서브트리 ]`. 예를 들어 위 그림(12-5)의 트리는 `[ 9 | 3 | 1 2 7 ]`에 해당한다. + +위 그림(12-5)의 데이터를 예로 들면 다음 그림(12-6)의 단계로 나눌 수 있다. + +1. 전위 순회의 첫 원소 3은 루트 노드의 값이다. +2. `inorder`에서 루트 노드 3의 인덱스를 찾아 이를 기준으로 `inorder`를 `[ 9 | 3 | 1 2 7 ]`로 나눈다. +3. `inorder`의 분할 결과로 왼쪽 서브트리의 노드 수는 1, 오른쪽은 3임을 알 수 있다. 따라서 `preorder`를 `[ 3 | 9 | 2 1 7 ]`로 나눌 수 있다. + +![전위 순회와 중위 순회에서 서브트리 나누기](build_binary_tree_problem.assets/build_tree_preorder_inorder_division.png){ class="animation-figure" } + +

그림 12-6   전위 순회와 중위 순회에서 서브트리 나누기

+ +### 3.   변수로 서브트리 구간 표현 + +위 방법으로 **`preorder`와 `inorder`에서 루트 노드, 왼쪽 서브트리, 오른쪽 서브트리의 인덱스 구간을 구했다**. 이 구간들을 표현하려면 몇 개의 포인터 변수가 필요하다. + +- 현재 트리의 루트 노드가 `preorder`에서 갖는 인덱스를 $i$라고 한다. +- 현재 트리의 루트 노드가 `inorder`에서 갖는 인덱스를 $m$이라고 한다. +- 현재 트리가 `inorder`에서 차지하는 인덱스 구간을 $[l, r]$이라고 한다. + +아래 표(12-1)와 같이 이 변수들로 `preorder`에서 루트 노드의 인덱스와 `inorder`에서 서브트리의 인덱스 구간을 나타낼 수 있다. + +

표 12-1   전위 순회와 중위 순회에서 루트 노드와 서브트리의 인덱스

+ +
+ +| | `preorder`에서 루트 노드의 인덱스 | `inorder`에서 서브트리의 인덱스 구간 | +| ------ | ---------------------------- | ----------------------------- | +| 현재 트리 | $i$ | $[l, r]$ | +| 왼쪽 서브트리 | $i + 1$ | $[l, m-1]$ | +| 오른쪽 서브트리 | $i + 1 + (m - l)$ | $[m+1, r]$ | + +
+ +오른쪽 서브트리의 루트 인덱스에 있는 $(m-l)$은 '왼쪽 서브트리의 노드 수'를 뜻한다. 아래 그림(12-7)과 함께 이해해 보자. + +![루트 노드와 양쪽 서브트리의 인덱스 구간 표현](build_binary_tree_problem.assets/build_tree_division_pointers.png){ class="animation-figure" } + +

그림 12-7   루트 노드와 양쪽 서브트리의 인덱스 구간 표현

+ +### 4.   코드 구현 + +$m$을 더 효율적으로 조회하기 위해 해시 테이블 `hmap`에 배열 `inorder`의 원소와 인덱스 사이의 매핑을 저장한다. + +=== "Python" + + ```python title="build_tree.py" + def dfs( + preorder: list[int], + inorder_map: dict[int, int], + i: int, + l: int, + r: int, + ) -> TreeNode | None: + """이진 트리 구성: 분할 정복""" + # 서브트리 구간이 비어 있으면 종료한다 + if r - l < 0: + return None + # 루트 노드를 초기화한다 + root = TreeNode(preorder[i]) + # m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + m = inorder_map[preorder[i]] + # 부분 문제: 왼쪽 서브트리 구성 + root.left = dfs(preorder, inorder_map, i + 1, l, m - 1) + # 부분 문제: 오른쪽 서브트리 구성 + root.right = dfs(preorder, inorder_map, i + 1 + m - l, m + 1, r) + # 루트 노드를 반환한다 + return root + + def build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None: + """이진 트리 구성""" + # inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + inorder_map = {val: i for i, val in enumerate(inorder)} + root = dfs(preorder, inorder_map, 0, 0, len(inorder) - 1) + return root + ``` + +=== "C++" + + ```cpp title="build_tree.cpp" + /* 이진 트리 구성: 분할 정복 */ + TreeNode *dfs(vector &preorder, unordered_map &inorderMap, int i, int l, int r) { + // 서브트리 구간이 비어 있으면 종료한다 + if (r - l < 0) + return NULL; + // 루트 노드를 초기화한다 + TreeNode *root = new TreeNode(preorder[i]); + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + int m = inorderMap[preorder[i]]; + // 부분 문제: 왼쪽 서브트리 구성 + root->left = dfs(preorder, inorderMap, i + 1, l, m - 1); + // 부분 문제: 오른쪽 서브트리 구성 + root->right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r); + // 루트 노드를 반환한다 + return root; + } + + /* 이진 트리 구성 */ + TreeNode *buildTree(vector &preorder, vector &inorder) { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + unordered_map inorderMap; + for (int i = 0; i < inorder.size(); i++) { + inorderMap[inorder[i]] = i; + } + TreeNode *root = dfs(preorder, inorderMap, 0, 0, inorder.size() - 1); + return root; + } + ``` + +=== "Java" + + ```java title="build_tree.java" + /* 이진 트리 구성: 분할 정복 */ + TreeNode dfs(int[] preorder, Map inorderMap, int i, int l, int r) { + // 서브트리 구간이 비어 있으면 종료한다 + if (r - l < 0) + return null; + // 루트 노드를 초기화한다 + TreeNode root = new TreeNode(preorder[i]); + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + int m = inorderMap.get(preorder[i]); + // 부분 문제: 왼쪽 서브트리 구성 + root.left = dfs(preorder, inorderMap, i + 1, l, m - 1); + // 부분 문제: 오른쪽 서브트리 구성 + root.right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r); + // 루트 노드를 반환한다 + return root; + } + + /* 이진 트리 구성 */ + TreeNode buildTree(int[] preorder, int[] inorder) { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + Map inorderMap = new HashMap<>(); + for (int i = 0; i < inorder.length; i++) { + inorderMap.put(inorder[i], i); + } + TreeNode root = dfs(preorder, inorderMap, 0, 0, inorder.length - 1); + return root; + } + ``` + +=== "C#" + + ```csharp title="build_tree.cs" + /* 이진 트리 구성: 분할 정복 */ + TreeNode? DFS(int[] preorder, Dictionary inorderMap, int i, int l, int r) { + // 서브트리 구간이 비어 있으면 종료한다 + if (r - l < 0) + return null; + // 루트 노드를 초기화한다 + TreeNode root = new(preorder[i]); + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + int m = inorderMap[preorder[i]]; + // 부분 문제: 왼쪽 서브트리 구성 + root.left = DFS(preorder, inorderMap, i + 1, l, m - 1); + // 부분 문제: 오른쪽 서브트리 구성 + root.right = DFS(preorder, inorderMap, i + 1 + m - l, m + 1, r); + // 루트 노드를 반환한다 + return root; + } + + /* 이진 트리 구성 */ + TreeNode? BuildTree(int[] preorder, int[] inorder) { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + Dictionary inorderMap = []; + for (int i = 0; i < inorder.Length; i++) { + inorderMap.TryAdd(inorder[i], i); + } + TreeNode? root = DFS(preorder, inorderMap, 0, 0, inorder.Length - 1); + return root; + } + ``` + +=== "Go" + + ```go title="build_tree.go" + /* 이진 트리 구성: 분할 정복 */ + func dfsBuildTree(preorder []int, inorderMap map[int]int, i, l, r int) *TreeNode { + // 서브트리 구간이 비어 있으면 종료한다 + if r-l < 0 { + return nil + } + // 루트 노드를 초기화한다 + root := NewTreeNode(preorder[i]) + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + m := inorderMap[preorder[i]] + // 부분 문제: 왼쪽 서브트리 구성 + root.Left = dfsBuildTree(preorder, inorderMap, i+1, l, m-1) + // 부분 문제: 오른쪽 서브트리 구성 + root.Right = dfsBuildTree(preorder, inorderMap, i+1+m-l, m+1, r) + // 루트 노드를 반환한다 + return root + } + + /* 이진 트리 구성 */ + func buildTree(preorder, inorder []int) *TreeNode { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + inorderMap := make(map[int]int, len(inorder)) + for i := 0; i < len(inorder); i++ { + inorderMap[inorder[i]] = i + } + + root := dfsBuildTree(preorder, inorderMap, 0, 0, len(inorder)-1) + return root + } + ``` + +=== "Swift" + + ```swift title="build_tree.swift" + /* 이진 트리 구성: 분할 정복 */ + func dfs(preorder: [Int], inorderMap: [Int: Int], i: Int, l: Int, r: Int) -> TreeNode? { + // 서브트리 구간이 비어 있으면 종료한다 + if r - l < 0 { + return nil + } + // 루트 노드를 초기화한다 + let root = TreeNode(x: preorder[i]) + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + let m = inorderMap[preorder[i]]! + // 부분 문제: 왼쪽 서브트리 구성 + root.left = dfs(preorder: preorder, inorderMap: inorderMap, i: i + 1, l: l, r: m - 1) + // 부분 문제: 오른쪽 서브트리 구성 + root.right = dfs(preorder: preorder, inorderMap: inorderMap, i: i + 1 + m - l, l: m + 1, r: r) + // 루트 노드를 반환한다 + return root + } + + /* 이진 트리 구성 */ + func buildTree(preorder: [Int], inorder: [Int]) -> TreeNode? { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + let inorderMap = inorder.enumerated().reduce(into: [:]) { $0[$1.element] = $1.offset } + return dfs(preorder: preorder, inorderMap: inorderMap, i: inorder.startIndex, l: inorder.startIndex, r: inorder.endIndex - 1) + } + ``` + +=== "JS" + + ```javascript title="build_tree.js" + /* 이진 트리 구성: 분할 정복 */ + function dfs(preorder, inorderMap, i, l, r) { + // 서브트리 구간이 비어 있으면 종료한다 + if (r - l < 0) return null; + // 루트 노드를 초기화한다 + const root = new TreeNode(preorder[i]); + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + const m = inorderMap.get(preorder[i]); + // 부분 문제: 왼쪽 서브트리 구성 + root.left = dfs(preorder, inorderMap, i + 1, l, m - 1); + // 부분 문제: 오른쪽 서브트리 구성 + root.right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r); + // 루트 노드를 반환한다 + return root; + } + + /* 이진 트리 구성 */ + function buildTree(preorder, inorder) { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + let inorderMap = new Map(); + for (let i = 0; i < inorder.length; i++) { + inorderMap.set(inorder[i], i); + } + const root = dfs(preorder, inorderMap, 0, 0, inorder.length - 1); + return root; + } + ``` + +=== "TS" + + ```typescript title="build_tree.ts" + /* 이진 트리 구성: 분할 정복 */ + function dfs( + preorder: number[], + inorderMap: Map, + i: number, + l: number, + r: number + ): TreeNode | null { + // 서브트리 구간이 비어 있으면 종료한다 + if (r - l < 0) return null; + // 루트 노드를 초기화한다 + const root: TreeNode = new TreeNode(preorder[i]); + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + const m = inorderMap.get(preorder[i]); + // 부분 문제: 왼쪽 서브트리 구성 + root.left = dfs(preorder, inorderMap, i + 1, l, m - 1); + // 부분 문제: 오른쪽 서브트리 구성 + root.right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r); + // 루트 노드를 반환한다 + return root; + } + + /* 이진 트리 구성 */ + function buildTree(preorder: number[], inorder: number[]): TreeNode | null { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + let inorderMap = new Map(); + for (let i = 0; i < inorder.length; i++) { + inorderMap.set(inorder[i], i); + } + const root = dfs(preorder, inorderMap, 0, 0, inorder.length - 1); + return root; + } + ``` + +=== "Dart" + + ```dart title="build_tree.dart" + /* 이진 트리 구성: 분할 정복 */ + TreeNode? dfs( + List preorder, + Map inorderMap, + int i, + int l, + int r, + ) { + // 서브트리 구간이 비어 있으면 종료한다 + if (r - l < 0) { + return null; + } + // 루트 노드를 초기화한다 + TreeNode? root = TreeNode(preorder[i]); + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + int m = inorderMap[preorder[i]]!; + // 부분 문제: 왼쪽 서브트리 구성 + root.left = dfs(preorder, inorderMap, i + 1, l, m - 1); + // 부분 문제: 오른쪽 서브트리 구성 + root.right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r); + // 루트 노드를 반환한다 + return root; + } + + /* 이진 트리 구성 */ + TreeNode? buildTree(List preorder, List inorder) { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + Map inorderMap = {}; + for (int i = 0; i < inorder.length; i++) { + inorderMap[inorder[i]] = i; + } + TreeNode? root = dfs(preorder, inorderMap, 0, 0, inorder.length - 1); + return root; + } + ``` + +=== "Rust" + + ```rust title="build_tree.rs" + /* 이진 트리 구성: 분할 정복 */ + fn dfs( + preorder: &[i32], + inorder_map: &HashMap, + i: i32, + l: i32, + r: i32, + ) -> Option>> { + // 서브트리 구간이 비어 있으면 종료한다 + if r - l < 0 { + return None; + } + // 루트 노드를 초기화한다 + let root = TreeNode::new(preorder[i as usize]); + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + let m = inorder_map.get(&preorder[i as usize]).unwrap(); + // 부분 문제: 왼쪽 서브트리 구성 + root.borrow_mut().left = dfs(preorder, inorder_map, i + 1, l, m - 1); + // 부분 문제: 오른쪽 서브트리 구성 + root.borrow_mut().right = dfs(preorder, inorder_map, i + 1 + m - l, m + 1, r); + // 루트 노드를 반환한다 + Some(root) + } + + /* 이진 트리 구성 */ + fn build_tree(preorder: &[i32], inorder: &[i32]) -> Option>> { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + let mut inorder_map: HashMap = HashMap::new(); + for i in 0..inorder.len() { + inorder_map.insert(inorder[i], i as i32); + } + let root = dfs(preorder, &inorder_map, 0, 0, inorder.len() as i32 - 1); + root + } + ``` + +=== "C" + + ```c title="build_tree.c" + /* 이진 트리 구성: 분할 정복 */ + TreeNode *dfs(int *preorder, int *inorderMap, int i, int l, int r, int size) { + // 서브트리 구간이 비어 있으면 종료한다 + if (r - l < 0) + return NULL; + // 루트 노드를 초기화한다 + TreeNode *root = (TreeNode *)malloc(sizeof(TreeNode)); + root->val = preorder[i]; + root->left = NULL; + root->right = NULL; + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + int m = inorderMap[preorder[i]]; + // 부분 문제: 왼쪽 서브트리 구성 + root->left = dfs(preorder, inorderMap, i + 1, l, m - 1, size); + // 부분 문제: 오른쪽 서브트리 구성 + root->right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r, size); + // 루트 노드를 반환한다 + return root; + } + + /* 이진 트리 구성 */ + TreeNode *buildTree(int *preorder, int preorderSize, int *inorder, int inorderSize) { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + int *inorderMap = (int *)malloc(sizeof(int) * MAX_SIZE); + for (int i = 0; i < inorderSize; i++) { + inorderMap[inorder[i]] = i; + } + TreeNode *root = dfs(preorder, inorderMap, 0, 0, inorderSize - 1, inorderSize); + free(inorderMap); + return root; + } + ``` + +=== "Kotlin" + + ```kotlin title="build_tree.kt" + /* 이진 트리 구성: 분할 정복 */ + fun dfs( + preorder: IntArray, + inorderMap: Map, + i: Int, + l: Int, + r: Int + ): TreeNode? { + // 서브트리 구간이 비어 있으면 종료한다 + if (r - l < 0) return null + // 루트 노드를 초기화한다 + val root = TreeNode(preorder[i]) + // m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + val m = inorderMap[preorder[i]]!! + // 부분 문제: 왼쪽 서브트리 구성 + root.left = dfs(preorder, inorderMap, i + 1, l, m - 1) + // 부분 문제: 오른쪽 서브트리 구성 + root.right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r) + // 루트 노드를 반환한다 + return root + } + + /* 이진 트리 구성 */ + fun buildTree(preorder: IntArray, inorder: IntArray): TreeNode? { + // inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + val inorderMap = HashMap() + for (i in inorder.indices) { + inorderMap[inorder[i]] = i + } + val root = dfs(preorder, inorderMap, 0, 0, inorder.size - 1) + return root + } + ``` + +=== "Ruby" + + ```ruby title="build_tree.rb" + ### 이진 트리 구성: 분할 정복 ### + def dfs(preorder, inorder_map, i, l, r) + # 서브트리 구간이 비어 있으면 종료한다 + return if r - l < 0 + + # 루트 노드를 초기화한다 + root = TreeNode.new(preorder[i]) + # m을 조회하여 왼쪽과 오른쪽 서브트리를 나눈다 + m = inorder_map[preorder[i]] + # 부분 문제: 왼쪽 서브트리 구성 + root.left = dfs(preorder, inorder_map, i + 1, l, m - 1) + # 부분 문제: 오른쪽 서브트리 구성 + root.right = dfs(preorder, inorder_map, i + 1 + m - l, m + 1, r) + + # 루트 노드를 반환한다 + root + end + + ### 이진 트리 구성 ### + def build_tree(preorder, inorder) + # inorder 원소와 인덱스의 대응 관계를 저장할 해시 테이블을 초기화한다 + inorder_map = {} + inorder.each_with_index { |val, i| inorder_map[val] = i } + dfs(preorder, inorder_map, 0, 0, inorder.length - 1) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(12-8)은 이진 트리 구성의 재귀 과정을 보여 준다. 각 노드는 아래로 '호출'하는 과정에서 만들어지고, 간선(참조)은 위로 '반환'하는 과정에서 만들어진다. + +=== "<1>" + ![이진 트리 구성의 재귀 과정](build_binary_tree_problem.assets/built_tree_step1.png){ class="animation-figure" } + +=== "<2>" + ![이진 트리 구성 2단계](build_binary_tree_problem.assets/built_tree_step2.png){ class="animation-figure" } + +=== "<3>" + ![이진 트리 구성 3단계](build_binary_tree_problem.assets/built_tree_step3.png){ class="animation-figure" } + +=== "<4>" + ![이진 트리 구성 4단계](build_binary_tree_problem.assets/built_tree_step4.png){ class="animation-figure" } + +=== "<5>" + ![이진 트리 구성 5단계](build_binary_tree_problem.assets/built_tree_step5.png){ class="animation-figure" } + +=== "<6>" + ![이진 트리 구성 6단계](build_binary_tree_problem.assets/built_tree_step6.png){ class="animation-figure" } + +=== "<7>" + ![이진 트리 구성 7단계](build_binary_tree_problem.assets/built_tree_step7.png){ class="animation-figure" } + +=== "<8>" + ![이진 트리 구성 8단계](build_binary_tree_problem.assets/built_tree_step8.png){ class="animation-figure" } + +=== "<9>" + ![이진 트리 구성 9단계](build_binary_tree_problem.assets/built_tree_step9.png){ class="animation-figure" } + +

그림 12-8   이진 트리 구성의 재귀 과정

+ +각 재귀 함수에서 전위 순회 `preorder`와 중위 순회 `inorder`를 나눈 결과는 아래 그림(12-9)과 같다. + +![각 재귀 함수의 분할 결과](build_binary_tree_problem.assets/built_tree_overall.png){ class="animation-figure" } + +

그림 12-9   각 재귀 함수의 분할 결과

+ +트리의 노드 수가 $n$일 때 노드 하나를 초기화하는 데(재귀 함수 `dfs()` 한 번 실행) $O(1)$ 시간이 걸린다. **따라서 전체 시간 복잡도는 $O(n)$이다**. + +해시 테이블은 `inorder` 원소와 인덱스의 매핑을 저장하므로 공간 복잡도가 $O(n)$이다. 최악의 경우인 이진 트리가 연결 리스트로 퇴화한 경우에는 재귀 깊이가 $n$에 이르러 $O(n)$의 스택 프레임 공간을 사용한다. **따라서 전체 공간 복잡도는 $O(n)$이다**. diff --git a/ko/docs/chapter_divide_and_conquer/divide_and_conquer.md b/ko/docs/chapter_divide_and_conquer/divide_and_conquer.md new file mode 100644 index 000000000..b054ea782 --- /dev/null +++ b/ko/docs/chapter_divide_and_conquer/divide_and_conquer.md @@ -0,0 +1,101 @@ +--- +comments: true +--- + +# 12.1   분할 정복 알고리즘 + +분할 정복(divide and conquer)은 '나누어 해결한다'는 뜻으로, 매우 중요하고 흔히 쓰이는 알고리즘 전략이다. 보통 재귀로 구현하며 '분할'과 '정복'의 두 단계로 이루어진다. + +1. **분할(나누는 단계)**: 원래 문제를 둘 이상의 부분 문제로 재귀적으로 나누고, 가장 작은 부분 문제에 도달하면 멈춘다. +2. **정복(합치는 단계)**: 해를 이미 아는 가장 작은 부분 문제부터 시작하여 아래에서 위로 부분 문제의 해를 합침으로써 원래 문제의 해를 구성한다. + +아래 그림(12-1)의 '병합 정렬'은 분할 정복 전략의 대표적인 응용이다. + +1. **분할**: 원래 배열(원래 문제)을 두 부분 배열(부분 문제)로 재귀적으로 나누고, 부분 배열에 원소가 하나만 남으면(가장 작은 부분 문제) 멈춘다. +2. **정복**: 정렬된 부분 배열(부분 문제의 해)을 아래에서 위로 합쳐 정렬된 원래 배열(원래 문제의 해)을 얻는다. + +![병합 정렬의 분할 정복 전략](divide_and_conquer.assets/divide_and_conquer_merge_sort.png){ class="animation-figure" } + +

그림 12-1   병합 정렬의 분할 정복 전략

+ +## 12.1.1   분할 정복 문제를 판단하는 방법 + +문제가 분할 정복에 적합한지는 보통 다음 기준으로 판단할 수 있다. + +1. **문제를 나눌 수 있다**: 원래 문제를 크기가 더 작고 형태가 비슷한 부분 문제로 나눌 수 있으며, 같은 방식으로 재귀적으로 나눌 수 있다. +2. **부분 문제가 독립적이다**: 부분 문제들이 겹치지 않고 서로 의존하지 않으며, 독립적으로 해결할 수 있다. +3. **부분 문제의 해를 합칠 수 있다**: 부분 문제의 해를 합쳐 원래 문제의 해를 얻는다. + +병합 정렬은 위 세 기준을 모두 만족한다. + +1. **문제를 나눌 수 있다**: 배열(원래 문제)을 두 부분 배열(부분 문제)로 재귀적으로 나눈다. +2. **부분 문제가 독립적이다**: 각 부분 배열을 독립적으로 정렬할 수 있다(부분 문제를 독립적으로 해결할 수 있다). +3. **부분 문제의 해를 합칠 수 있다**: 정렬된 두 부분 배열(부분 문제의 해)을 하나의 정렬된 배열(원래 문제의 해)로 합칠 수 있다. + +## 12.1.2   분할 정복으로 효율 높이기 + +**분할 정복은 알고리즘 문제를 효과적으로 해결할 뿐 아니라 효율을 높이는 경우도 많다**. 퀵 정렬, 병합 정렬, 힙 정렬이 선택 정렬, 버블 정렬, 삽입 정렬보다 빠른 이유도 분할 정복 전략을 사용하기 때문이다. + +여기서 궁금해진다. **분할 정복이 알고리즘의 효율을 높이는 근본적인 원리는 무엇일까**? 큰 문제를 여러 부분 문제로 나누고, 부분 문제를 해결하고, 그 해를 합쳐 원래 문제의 해를 구하는 과정이 왜 원래 문제를 직접 푸는 것보다 효율적일까? 연산 수와 병렬 계산이라는 두 관점에서 살펴볼 수 있다. + +### 1.   연산 수 최적화 + +'버블 정렬'은 길이 $n$의 배열을 처리하는 데 $O(n^2)$ 시간이 걸린다. 아래 그림(12-2)처럼 중간에서 두 부분 배열로 나눈다고 하자. 분할에는 $O(n)$ 시간, 각 부분 배열의 정렬에는 $O((n / 2)^2)$ 시간, 두 부분 배열의 병합에는 $O(n)$ 시간이 걸리므로 전체 시간 복잡도는 다음과 같다. + +$$ +O(n + (\frac{n}{2})^2 \times 2 + n) = O(\frac{n^2}{2} + 2n) +$$ + +![배열 분할 전후의 버블 정렬](divide_and_conquer.assets/divide_and_conquer_bubble_sort.png){ class="animation-figure" } + +

그림 12-2   배열 분할 전후의 버블 정렬

+ +이어서 왼쪽은 분할 전, 오른쪽은 분할 후의 총연산 수를 나타내는 다음 부등식을 계산해 보자. + +$$ +\begin{aligned} +n^2 & > \frac{n^2}{2} + 2n \newline +n^2 - \frac{n^2}{2} - 2n & > 0 \newline +n(n - 4) & > 0 +\end{aligned} +$$ + +**이는 $n > 4$일 때 분할 후의 연산 수가 더 적어 정렬 효율이 높아질 것임을 뜻한다**. 분할 후에도 시간 복잡도는 여전히 이차인 $O(n^2)$이며, 상수항만 작아졌다는 점에 주의하자. + +여기서 더 나아가 **부분 배열을 계속 중간에서 두 개로 나누고**, 원소가 하나만 남을 때까지 반복하면 어떨까? 이 발상은 바로 '병합 정렬'이며 시간 복잡도는 $O(n \log n)$이다. + +다시 생각해 보자. **분할 지점을 더 많이 두어** 원래 배열을 $k$ 개의 부분 배열로 고르게 나누면 어떨까? 이는 '버킷 정렬'과 매우 비슷하다. 대규모 데이터 정렬에 적합하며 이론적으로 시간 복잡도가 $O(n + k)$에 이를 수 있다. + +### 2.   병렬 계산 최적화 + +분할 정복으로 만들어진 부분 문제는 서로 독립적이므로 **보통 병렬로 해결할 수 있다**. 즉, 분할 정복은 알고리즘의 시간 복잡도를 줄일 뿐 아니라 **운영체제의 병렬 최적화에도 유리하다**. + +병렬 최적화는 멀티코어나 다중 프로세서 환경에서 특히 효과적이다. 여러 부분 문제를 동시에 처리하여 계산 자원을 더 충분히 활용하고 전체 실행 시간을 크게 줄일 수 있기 때문이다. + +예를 들어 아래 그림(12-3)의 '버킷 정렬'에서 대규모 데이터를 각 버킷에 고르게 분배하면, 버킷별 정렬 작업을 여러 계산 장치에 나누어 맡긴 뒤 결과를 합칠 수 있다. + +![버킷 정렬의 병렬 계산](divide_and_conquer.assets/divide_and_conquer_parallel_computing.png){ class="animation-figure" } + +

그림 12-3   버킷 정렬의 병렬 계산

+ +## 12.1.3   분할 정복의 주요 응용 + +분할 정복은 여러 대표적인 알고리즘 문제를 해결할 수 있다. + +- **가장 가까운 점 쌍 찾기**: 먼저 점 집합을 두 부분으로 나누고 각 부분에서 가장 가까운 점 쌍을 찾은 뒤, 마지막으로 두 부분에 걸쳐 있는 가장 가까운 점 쌍을 찾는다. +- **큰 정수의 곱셈**: Karatsuba 알고리즘은 큰 정수의 곱셈을 더 작은 정수들의 곱셈과 덧셈으로 나눈다. +- **행렬 곱셈**: Strassen 알고리즘은 큰 행렬의 곱셈을 작은 행렬들의 곱셈과 덧셈으로 나눈다. +- **하노이의 탑 문제**: 재귀로 해결할 수 있는 하노이의 탑은 분할 정복 전략의 대표적인 응용이다. +- **역전 쌍 구하기**: 수열에서 앞의 수가 뒤의 수보다 크면 두 수가 역전 쌍을 이룬다. 역전 쌍 문제는 분할 정복의 아이디어로 병합 정렬을 이용하여 해결할 수 있다. + +또한 분할 정복은 알고리즘과 자료 구조의 설계에도 매우 널리 쓰인다. + +- **이진 탐색**: 정렬된 배열을 중간 인덱스에서 두 부분으로 나누고, 목표 값과 중간 원소를 비교한 결과에 따라 제외할 절반을 정한다. 남은 구간에서 같은 이진 탐색 연산을 수행한다. +- **병합 정렬**: 이 절의 앞부분에서 소개했으므로 다시 설명하지 않는다. +- **퀵 정렬**: 피벗을 하나 고르고 피벗보다 작은 원소와 큰 원소를 각각 두 부분 배열로 나눈다. 부분 배열에 원소가 하나만 남을 때까지 같은 분할을 반복한다. +- **버킷 정렬**: 데이터를 여러 버킷에 분산하고 각 버킷의 원소를 정렬한 뒤, 버킷별 원소를 차례로 꺼내 정렬된 배열을 얻는다. +- **트리**: 이진 탐색 트리, AVL 트리, 레드-블랙 트리, B 트리, B+ 트리 등의 탐색, 삽입, 삭제 연산은 모두 분할 정복 전략의 응용으로 볼 수 있다. +- **힙**: 특수한 완전 이진 트리인 힙의 삽입, 삭제, 재정렬 등에도 분할 정복의 아이디어가 숨어 있다. +- **해시 테이블**: 분할 정복을 직접 적용하지는 않지만 일부 해시 충돌 해결 방법에는 간접적으로 사용된다. 예를 들어 체이닝의 긴 연결 리스트를 레드-블랙 트리로 바꾸어 조회 효율을 높인다. + +이처럼 **분할 정복은 조용히 스며들어 작용하는 알고리즘 사고방식**이며, 다양한 알고리즘과 자료 구조 속에 숨어 있다. diff --git a/ko/docs/chapter_divide_and_conquer/exercises.md b/ko/docs/chapter_divide_and_conquer/exercises.md new file mode 100644 index 000000000..d7ce541bf --- /dev/null +++ b/ko/docs/chapter_divide_and_conquer/exercises.md @@ -0,0 +1,293 @@ +--- +comments: true +--- + +# 12.6   연습 문제 + +## 12.6.1   개념 확인 + +### 1.   어떤 작업에 분할 정복이 적합한가 + +한 학생이 ‘먼저 둘로 나누고, 각각 해결한 뒤, 결과를 합치는’ 방식으로 다음 작업을 수행하려 한다. +각 작업이 ‘분할 정복에 적합함’, ‘분할 정복이 가능하지만 전체 작업량은 줄어들지 않음’, ‘두 부분을 독립적으로 해결할 수 없음’ 중 어디에 해당하는지 판단하고 이유를 설명하라. + + + +1. 정렬되지 않은 배열을 정렬한다. +2. 배열의 최댓값을 구한다. +3. 일련의 스택 연산 `push(x)`, `pop()`을 순서대로 실행하고 각 `pop()`에서 얻은 원소를 출력한다. + +??? success "참고 답안" + + 1. 적합함: 반으로 나눈 뒤 두 부분을 독립적으로 정렬하고, $O(n)$ 시간에 병합한다. 이것이 바로 병합 정렬이다. + 2. 분할 정복이 가능하지만 전체 작업량은 줄어들지 않음: 왼쪽과 오른쪽을 합치면 여전히 $n$개 원소를 모두 확인해야 하므로, + 직접 순회하는 것과 마찬가지로 $O(n)$ 시간이 든다. + 3. 두 부분을 독립적으로 해결할 수 없음: 후반부를 시작할 때의 스택 내용은 전반부의 실행 결과에 따라 달라진다. + 따라서 서로의 결과를 모르는 상태에서 두 부분을 독립적으로 완료할 수 없다. + +### 2.   빠른 거듭제곱은 계산을 어떻게 줄이는가 + +다음 재귀 함수는 분할 정복으로 $x^n$을 계산한다. + +=== "Python" + + ```python title="fast_power.py" + def fast_pow(x: int, n: int) -> int: + """빠른 거듭제곱""" + if n == 0: + return 1 + half = fast_pow(x, n // 2) + if n % 2 == 0: + return half * half + return half * half * x + ``` + +=== "C++" + + ```cpp title="fast_power.cpp" + /* 빠른 거듭제곱 */ + int fastPow(int x, int n) { + if (n == 0) { + return 1; + } + int half = fastPow(x, n / 2); + if (n % 2 == 0) { + return half * half; + } + return half * half * x; + } + ``` + +=== "Java" + + ```java title="fast_power.java" + /* 빠른 거듭제곱 */ + int fastPow(int x, int n) { + if (n == 0) { + return 1; + } + int half = fastPow(x, n / 2); + if (n % 2 == 0) { + return half * half; + } + return half * half * x; + } + ``` + +=== "C#" + + ```csharp title="fast_power.cs" + /* 빠른 거듭제곱 */ + int FastPow(int x, int n) { + if (n == 0) { + return 1; + } + int half = FastPow(x, n / 2); + if (n % 2 == 0) { + return half * half; + } + return half * half * x; + } + ``` + +=== "Go" + + ```go title="fast_power.go" + /* 빠른 거듭제곱 */ + func fastPow(x, n int) int { + if n == 0 { + return 1 + } + half := fastPow(x, n/2) + if n%2 == 0 { + return half * half + } + return half * half * x + } + ``` + +=== "Swift" + + ```swift title="fast_power.swift" + /* 빠른 거듭제곱 */ + func fastPow(x: Int, n: Int) -> Int { + if n == 0 { + return 1 + } + let half = fastPow(x: x, n: n / 2) + if n % 2 == 0 { + return half * half + } + return half * half * x + } + ``` + +=== "JS" + + ```javascript title="fast_power.js" + /* 빠른 거듭제곱 */ + function fastPow(x, n) { + if (n === 0) { + return 1; + } + const half = fastPow(x, Math.floor(n / 2)); + if (n % 2 === 0) { + return half * half; + } + return half * half * x; + } + ``` + +=== "TS" + + ```typescript title="fast_power.ts" + /* 빠른 거듭제곱 */ + function fastPow(x: number, n: number): number { + if (n === 0) { + return 1; + } + const half = fastPow(x, Math.floor(n / 2)); + if (n % 2 === 0) { + return half * half; + } + return half * half * x; + } + ``` + +=== "Dart" + + ```dart title="fast_power.dart" + /* 빠른 거듭제곱 */ + int fastPow(int x, int n) { + if (n == 0) { + return 1; + } + int half = fastPow(x, n ~/ 2); + if (n % 2 == 0) { + return half * half; + } + return half * half * x; + } + ``` + +=== "Rust" + + ```rust title="fast_power.rs" + /* 빠른 거듭제곱 */ + fn fast_pow(x: i32, n: i32) -> i32 { + if n == 0 { + return 1; + } + let half = fast_pow(x, n / 2); + if n % 2 == 0 { + return half * half; + } + half * half * x + } + ``` + +=== "C" + + ```c title="fast_power.c" + /* 빠른 거듭제곱 */ + int fastPow(int x, int n) { + if (n == 0) { + return 1; + } + int half = fastPow(x, n / 2); + if (n % 2 == 0) { + return half * half; + } + return half * half * x; + } + ``` + +=== "Kotlin" + + ```kotlin title="fast_power.kt" + /* 빠른 거듭제곱 */ + fun fastPow(x: Int, n: Int): Int { + if (n == 0) { + return 1 + } + val half = fastPow(x, n / 2) + if (n % 2 == 0) { + return half * half + } + return half * half * x + } + ``` + +=== "Ruby" + + ```ruby title="fast_power.rb" + ### 빠른 거듭제곱 ### + def fast_pow(x, n) + return 1 if n == 0 + + half = fast_pow(x, n / 2) + return half * half if n.even? + + half * half * x + end + ``` + +`x = 3`, `n = 5`로 설정하고 이 함수를 실행한다. + + + +1. 재귀 호출에서 매개변수 `n`은 차례대로 어떤 값이 되는가? +2. 가장 깊은 호출부터 반환할 때 각 호출은 차례대로 어떤 값을 반환하는가? +3. 곱셈 양쪽에서 같은 부분 문제를 한 번씩 호출하지 않고, 먼저 재귀 호출의 결과를 `half`에 저장하는 이유는 무엇인가? + +??? success "참고 답안" + + 1. 매개변수는 `5 → 2 → 1 → 0` 순으로 바뀐다. 종료 조건에 도달할 때까지 매번 지수를 절반으로 줄인다. + + 2. `n = 0`이면 1을 반환한다. `n = 1`이면 $1×1×3=3$을 반환한다. + `n = 2`이면 $3×3=9$를, `n = 5`이면 $9×9×3=243$을 반환한다. + + 3. 곱셈 양쪽에서 같은 부분 문제를 한 번씩 호출하면 두 재귀 호출은 완전히 같은 계산을 수행한다. + 먼저 결과를 `half`에 저장하면 각 단계에서 재귀 호출을 한 번만 하면 되고, 재귀 깊이는 대략 $\log n$이다. + 두 번 호출하면 같은 계산이 대량으로 반복된다. + +### 3.   순회 결과에서 왼쪽 서브트리와 오른쪽 서브트리 나누기 + +중복된 노드가 없는 이진 트리의 전위 순회와 중위 순회 결과는 다음과 같다. + +- 전위 순회: `[A, B, D, E, C]` +- 중위 순회: `[D, B, E, A, C]` + +루트 노드 수준에서만 나누고, 더 깊이 재귀적으로 나누거나 트리 전체를 그릴 필요는 없다. + + + +1. 루트 노드는 무엇인가? +2. 중위 순회에서 왼쪽 서브트리와 오른쪽 서브트리에 해당하는 구간은 각각 어디인가? +3. 전위 순회에서 왼쪽 서브트리와 오른쪽 서브트리에 해당하는 구간은 각각 어디인가? 루트 노드의 직접적인 자식은 누구인가? + +??? success "참고 답안" + + 1. 전위 순회의 첫 번째 노드가 루트 노드이므로 루트는 `A`다. + + 2. `A`를 기준으로 중위 순회가 두 부분으로 나뉜다. 왼쪽 서브트리는 `[D, B, E]`, 오른쪽 서브트리는 `[C]`다. + + 3. 왼쪽 서브트리에 노드가 3개 있으므로 전위 순회에서 루트 노드 `A` 다음의 원소 3개가 왼쪽 서브트리에 속한다. + 즉 `[B, D, E]`가 왼쪽 서브트리이고, 나머지 `[C]`가 오른쪽 서브트리다. + 따라서 루트 노드의 왼쪽 자식은 `B`, 오른쪽 자식은 `C`다. + +## 12.6.2   프로그래밍 연습 + +### 1.   빠른 거듭제곱 + +실수 `x`와 정수 `n`이 주어진다. 언어에서 제공하는 거듭제곱 함수를 호출하지 않고 $x^n$을 계산하라. +재귀적인 분할 정복을 사용하여 매번 지수를 절반으로 줄이고, 이미 계산한 부분 문제의 결과를 재사용해야 한다. +이 문제에서는 `x = 0`인 경우를 포함하여 $x^0=1$로 정한다. `n < 0`일 때는 `x != 0`임이 보장되며, 답을 $(1/x)^{-n}$으로 바꾸어 구할 수 있다. + +??? tip "풀이 힌트" + + 1. n이 0이면 답은 1이다. + 2. x의 n // 2제곱을 계산하여 half에 저장한다. 같은 재귀 호출을 두 번 하지 마라. + 3. n < 0이면 먼저 x를 1 / x로 바꾸고 n을 -n으로 바꾼다. C++이나 Java에서는 n을 먼저 64비트 정수로 변환하면 가장 작은 32비트 정수의 부호를 반대로 바꿀 때 발생하는 오버플로를 피할 수 있다. + +[LeetCode](https://leetcode.com/problems/powx-n/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_divide_and_conquer/hanota_problem.md b/ko/docs/chapter_divide_and_conquer/hanota_problem.md new file mode 100644 index 000000000..dda24fac6 --- /dev/null +++ b/ko/docs/chapter_divide_and_conquer/hanota_problem.md @@ -0,0 +1,560 @@ +--- +comments: true +--- + +# 12.4   하노이의 탑 문제 + +병합 정렬과 이진 트리 구성에서는 원래 문제를 크기가 절반인 두 부분 문제로 나누었다. 하지만 하노이의 탑 문제에서는 다른 분할 전략을 사용한다. + +!!! question "문제" + + 기둥 세 개를 `A`, `B`, `C`라고 하자. 처음에는 기둥 `A`에 원판 $n$ 개가 위에서 아래로 작은 것부터 큰 것 순서로 꽂혀 있다. 목표는 원래 순서를 유지하면서 이 원판 $n$ 개를 기둥 `C`로 옮기는 것이다(아래 그림(12-10) 참고). 원판을 옮길 때는 다음 규칙을 지켜야 한다. + + 1. 원판은 한 기둥의 맨 위에서만 꺼내어 다른 기둥의 맨 위에 놓을 수 있다. + 2. 한 번에 원판 하나만 옮길 수 있다. + 3. 작은 원판은 항상 큰 원판 위에 있어야 한다. + +![하노이의 탑 문제 예시](hanota_problem.assets/hanota_example.png){ class="animation-figure" } + +

그림 12-10   하노이의 탑 문제 예시

+ +**크기가 $i$인 하노이의 탑 문제를 $f(i)$라고 하자**. 예를 들어 $f(3)$은 원판 $3$ 개를 `A`에서 `C`로 옮기는 문제다. + +### 1.   기본 경우 살펴보기 + +아래 그림(12-11)과 같이 $f(1)$, 즉 원판이 하나뿐이면 `A`에서 `C`로 바로 옮기면 된다. + +=== "<1>" + ![크기 1인 문제의 해](hanota_problem.assets/hanota_f1_step1.png){ class="animation-figure" } + +=== "<2>" + ![원판 1개 이동 2단계](hanota_problem.assets/hanota_f1_step2.png){ class="animation-figure" } + +

그림 12-11   크기 1인 문제의 해

+ +아래 그림(12-12)과 같이 $f(2)$, 즉 원판이 두 개이면 **작은 원판이 항상 큰 원판 위에 있어야 하므로 `B`를 이용해야 한다**. + +1. 위의 작은 원판을 `A`에서 `B`로 옮긴다. +2. 큰 원판을 `A`에서 `C`로 옮긴다. +3. 작은 원판을 `B`에서 `C`로 옮긴다. + +=== "<1>" + ![크기 2인 문제의 해](hanota_problem.assets/hanota_f2_step1.png){ class="animation-figure" } + +=== "<2>" + ![원판 2개 이동 2단계](hanota_problem.assets/hanota_f2_step2.png){ class="animation-figure" } + +=== "<3>" + ![원판 2개 이동 3단계](hanota_problem.assets/hanota_f2_step3.png){ class="animation-figure" } + +=== "<4>" + ![원판 2개 이동 4단계](hanota_problem.assets/hanota_f2_step4.png){ class="animation-figure" } + +

그림 12-12   크기 2인 문제의 해

+ +$f(2)$의 해결 과정은 **`B`를 이용하여 원판 두 개를 `A`에서 `C`로 옮기는 것**으로 요약할 수 있다. 여기서 `C`를 목표 기둥, `B`를 보조 기둥이라고 한다. + +### 2.   부분 문제로 나누기 + +$f(3)$, 즉 원판이 세 개이면 상황이 조금 더 복잡해진다. + +$f(1)$과 $f(2)$의 해를 이미 알고 있으므로 분할 정복의 관점에서 생각해 보자. **`A` 위쪽의 원판 두 개를 하나의 묶음으로 보고** 아래 그림(12-13)의 단계를 수행하면 세 원판을 `A`에서 `C`로 옮길 수 있다. + +1. `B`를 목표 기둥, `C`를 보조 기둥으로 삼아 원판 두 개를 `A`에서 `B`로 옮긴다. +2. `A`에 남은 원판 하나를 `C`로 바로 옮긴다. +3. `C`를 목표 기둥, `A`를 보조 기둥으로 삼아 원판 두 개를 `B`에서 `C`로 옮긴다. + +=== "<1>" + ![크기 3인 문제의 해](hanota_problem.assets/hanota_f3_step1.png){ class="animation-figure" } + +=== "<2>" + ![원판 3개 이동 2단계](hanota_problem.assets/hanota_f3_step2.png){ class="animation-figure" } + +=== "<3>" + ![원판 3개 이동 3단계](hanota_problem.assets/hanota_f3_step3.png){ class="animation-figure" } + +=== "<4>" + ![원판 3개 이동 4단계](hanota_problem.assets/hanota_f3_step4.png){ class="animation-figure" } + +

그림 12-13   크기 3인 문제의 해

+ +본질적으로 **문제 $f(3)$을 부분 문제 $f(2)$ 두 개와 $f(1)$ 하나로 나눈 것**이다. 세 부분 문제를 순서대로 해결하면 원래 문제도 해결된다. 이는 부분 문제들이 독립적이고 해를 합칠 수 있음을 보여 준다. + +이제 아래 그림(12-14)과 같이 하노이의 탑을 푸는 분할 정복 전략을 정리할 수 있다. 원래 문제 $f(n)$을 부분 문제 $f(n-1)$ 두 개와 $f(1)$ 하나로 나누고, 다음 순서로 해결한다. + +1. `C`를 이용하여 원판 $n-1$ 개를 `A`에서 `B`로 옮긴다. +2. 남은 원판 $1$ 개를 `A`에서 `C`로 바로 옮긴다. +3. `A`를 이용하여 원판 $n-1$ 개를 `B`에서 `C`로 옮긴다. + +두 부분 문제 $f(n-1)$도 **같은 방식으로 재귀적으로 나눌 수 있으며**, 가장 작은 부분 문제 $f(1)$에 도달하면 멈춘다. $f(1)$의 해는 이미 알고 있고, 한 번만 옮기면 된다. + +![하노이의 탑을 푸는 분할 정복 전략](hanota_problem.assets/hanota_divide_and_conquer.png){ class="animation-figure" } + +

그림 12-14   하노이의 탑을 푸는 분할 정복 전략

+ +### 3.   코드 구현 + +코드에서는 재귀 함수 `dfs(i, src, buf, tar)`를 정의한다. 이 함수는 기둥 `src` 위쪽의 원판 $i$ 개를 보조 기둥 `buf`를 이용하여 목표 기둥 `tar`로 옮긴다. + +=== "Python" + + ```python title="hanota.py" + def move(src: list[int], tar: list[int]): + """원판 하나 이동""" + # src의 맨 위에서 원판 하나를 꺼낸다 + pan = src.pop() + # 원판을 tar의 맨 위에 놓는다 + tar.append(pan) + + def dfs(i: int, src: list[int], buf: list[int], tar: list[int]): + """하노이의 탑 문제 f(i) 풀기""" + # src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if i == 1: + move(src, tar) + return + # 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, tar, buf) + # 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, tar) + # 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, src, tar) + + def solve_hanota(A: list[int], B: list[int], C: list[int]): + """하노이의 탑 문제 풀기""" + n = len(A) + # B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(n, A, B, C) + ``` + +=== "C++" + + ```cpp title="hanota.cpp" + /* 원판 하나 이동 */ + void move(vector &src, vector &tar) { + // src의 맨 위에서 원판 하나를 꺼낸다 + int pan = src.back(); + src.pop_back(); + // 원판을 tar의 맨 위에 놓는다 + tar.push_back(pan); + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + void dfs(int i, vector &src, vector &buf, vector &tar) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if (i == 1) { + move(src, tar); + return; + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, tar, buf); + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, tar); + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, src, tar); + } + + /* 하노이의 탑 문제 풀기 */ + void solveHanota(vector &A, vector &B, vector &C) { + int n = A.size(); + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(n, A, B, C); + } + ``` + +=== "Java" + + ```java title="hanota.java" + /* 원판 하나 이동 */ + void move(List src, List tar) { + // src의 맨 위에서 원판 하나를 꺼낸다 + Integer pan = src.remove(src.size() - 1); + // 원판을 tar의 맨 위에 놓는다 + tar.add(pan); + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + void dfs(int i, List src, List buf, List tar) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if (i == 1) { + move(src, tar); + return; + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, tar, buf); + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, tar); + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, src, tar); + } + + /* 하노이의 탑 문제 풀기 */ + void solveHanota(List A, List B, List C) { + int n = A.size(); + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(n, A, B, C); + } + ``` + +=== "C#" + + ```csharp title="hanota.cs" + /* 원판 하나 이동 */ + void Move(List src, List tar) { + // src의 맨 위에서 원판 하나를 꺼낸다 + int pan = src[^1]; + src.RemoveAt(src.Count - 1); + // 원판을 tar의 맨 위에 놓는다 + tar.Add(pan); + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + void DFS(int i, List src, List buf, List tar) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if (i == 1) { + Move(src, tar); + return; + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + DFS(i - 1, src, tar, buf); + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + Move(src, tar); + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + DFS(i - 1, buf, src, tar); + } + + /* 하노이의 탑 문제 풀기 */ + void SolveHanota(List A, List B, List C) { + int n = A.Count; + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + DFS(n, A, B, C); + } + ``` + +=== "Go" + + ```go title="hanota.go" + /* 원판 하나 이동 */ + func move(src, tar *list.List) { + // src의 맨 위에서 원판 하나를 꺼낸다 + pan := src.Back() + // 원판을 tar의 맨 위에 놓는다 + tar.PushBack(pan.Value) + // src의 맨 위 원판을 제거한다 + src.Remove(pan) + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + func dfsHanota(i int, src, buf, tar *list.List) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if i == 1 { + move(src, tar) + return + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfsHanota(i-1, src, tar, buf) + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, tar) + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfsHanota(i-1, buf, src, tar) + } + + /* 하노이의 탑 문제 풀기 */ + func solveHanota(A, B, C *list.List) { + n := A.Len() + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfsHanota(n, A, B, C) + } + ``` + +=== "Swift" + + ```swift title="hanota.swift" + /* 원판 하나 이동 */ + func move(src: inout [Int], tar: inout [Int]) { + // src의 맨 위에서 원판 하나를 꺼낸다 + let pan = src.popLast()! + // 원판을 tar의 맨 위에 놓는다 + tar.append(pan) + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + func dfs(i: Int, src: inout [Int], buf: inout [Int], tar: inout [Int]) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if i == 1 { + move(src: &src, tar: &tar) + return + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i: i - 1, src: &src, buf: &tar, tar: &buf) + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src: &src, tar: &tar) + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i: i - 1, src: &buf, buf: &src, tar: &tar) + } + + /* 하노이의 탑 문제 풀기 */ + func solveHanota(A: inout [Int], B: inout [Int], C: inout [Int]) { + let n = A.count + // 리스트의 맨 뒤가 기둥의 맨 위이다 + // B를 이용하여 src의 맨 위 원판 n개를 C로 옮긴다 + dfs(i: n, src: &A, buf: &B, tar: &C) + } + ``` + +=== "JS" + + ```javascript title="hanota.js" + /* 원판 하나 이동 */ + function move(src, tar) { + // src의 맨 위에서 원판 하나를 꺼낸다 + const pan = src.pop(); + // 원판을 tar의 맨 위에 놓는다 + tar.push(pan); + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + function dfs(i, src, buf, tar) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if (i === 1) { + move(src, tar); + return; + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, tar, buf); + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, tar); + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, src, tar); + } + + /* 하노이의 탑 문제 풀기 */ + function solveHanota(A, B, C) { + const n = A.length; + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(n, A, B, C); + } + ``` + +=== "TS" + + ```typescript title="hanota.ts" + /* 원판 하나 이동 */ + function move(src: number[], tar: number[]): void { + // src의 맨 위에서 원판 하나를 꺼낸다 + const pan = src.pop(); + // 원판을 tar의 맨 위에 놓는다 + tar.push(pan); + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + function dfs(i: number, src: number[], buf: number[], tar: number[]): void { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if (i === 1) { + move(src, tar); + return; + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, tar, buf); + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, tar); + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, src, tar); + } + + /* 하노이의 탑 문제 풀기 */ + function solveHanota(A: number[], B: number[], C: number[]): void { + const n = A.length; + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(n, A, B, C); + } + ``` + +=== "Dart" + + ```dart title="hanota.dart" + /* 원판 하나 이동 */ + void move(List src, List tar) { + // src의 맨 위에서 원판 하나를 꺼낸다 + int pan = src.removeLast(); + // 원판을 tar의 맨 위에 놓는다 + tar.add(pan); + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + void dfs(int i, List src, List buf, List tar) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if (i == 1) { + move(src, tar); + return; + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, tar, buf); + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, tar); + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, src, tar); + } + + /* 하노이의 탑 문제 풀기 */ + void solveHanota(List A, List B, List C) { + int n = A.length; + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(n, A, B, C); + } + ``` + +=== "Rust" + + ```rust title="hanota.rs" + /* 원판 하나 이동 */ + fn move_pan(src: &mut Vec, tar: &mut Vec) { + // src의 맨 위에서 원판 하나를 꺼낸다 + let pan = src.pop().unwrap(); + // 원판을 tar의 맨 위에 놓는다 + tar.push(pan); + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + fn dfs(i: i32, src: &mut Vec, buf: &mut Vec, tar: &mut Vec) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if i == 1 { + move_pan(src, tar); + return; + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, tar, buf); + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move_pan(src, tar); + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, src, tar); + } + + /* 하노이의 탑 문제 풀기 */ + fn solve_hanota(A: &mut Vec, B: &mut Vec, C: &mut Vec) { + let n = A.len() as i32; + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(n, A, B, C); + } + ``` + +=== "C" + + ```c title="hanota.c" + /* 원판 하나 이동 */ + void move(int *src, int *srcSize, int *tar, int *tarSize) { + // src의 맨 위에서 원판 하나를 꺼낸다 + int pan = src[*srcSize - 1]; + src[*srcSize - 1] = 0; + (*srcSize)--; + // 원판을 tar의 맨 위에 놓는다 + tar[*tarSize] = pan; + (*tarSize)++; + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + void dfs(int i, int *src, int *srcSize, int *buf, int *bufSize, int *tar, int *tarSize) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if (i == 1) { + move(src, srcSize, tar, tarSize); + return; + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, srcSize, tar, tarSize, buf, bufSize); + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, srcSize, tar, tarSize); + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, bufSize, src, srcSize, tar, tarSize); + } + + /* 하노이의 탑 문제 풀기 */ + void solveHanota(int *A, int *ASize, int *B, int *BSize, int *C, int *CSize) { + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(*ASize, A, ASize, B, BSize, C, CSize); + } + ``` + +=== "Kotlin" + + ```kotlin title="hanota.kt" + /* 원판 하나 이동 */ + fun move(src: MutableList, tar: MutableList) { + // src의 맨 위에서 원판 하나를 꺼낸다 + val pan = src.removeAt(src.size - 1) + // 원판을 tar의 맨 위에 놓는다 + tar.add(pan) + } + + /* 하노이의 탑 문제 f(i) 풀기 */ + fun dfs(i: Int, src: MutableList, buf: MutableList, tar: MutableList) { + // src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if (i == 1) { + move(src, tar) + return + } + // 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, tar, buf) + // 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, tar) + // 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, src, tar) + } + + /* 하노이의 탑 문제 풀기 */ + fun solveHanota(A: MutableList, B: MutableList, C: MutableList) { + val n = A.size + // B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(n, A, B, C) + } + ``` + +=== "Ruby" + + ```ruby title="hanota.rb" + ### 원판 하나 이동 ### + def move(src, tar) + # src의 맨 위에서 원판 하나를 꺼낸다 + pan = src.pop + # 원판을 tar의 맨 위에 놓는다 + tar << pan + end + + ### 하노이의 탑 문제 f(i) 풀기 ### + def dfs(i, src, buf, tar) + # src에 원판이 하나만 남아 있으면 바로 tar로 옮긴다 + if i == 1 + move(src, tar) + return + end + + # 부분 문제 f(i-1): tar를 이용하여 src의 맨 위 원판 i-1개를 buf로 옮긴다 + dfs(i - 1, src, tar, buf) + # 부분 문제 f(1): src에 남은 원판 하나를 tar로 옮긴다 + move(src, tar) + # 부분 문제 f(i-1): src를 이용하여 buf의 맨 위 원판 i-1개를 tar로 옮긴다 + dfs(i - 1, buf, src, tar) + end + + ### 하노이의 탑 문제 풀기 ### + def solve_hanota(_A, _B, _C) + n = _A.length + # B를 이용하여 A의 맨 위 원판 n개를 C로 옮긴다 + dfs(n, _A, _B, _C) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(12-15)과 같이 하노이의 탑 문제는 높이가 $n$인 재귀 트리를 이룬다. 각 노드는 부분 문제 하나를 나타내며 호출된 `dfs()` 함수 하나에 대응한다. **따라서 시간 복잡도는 $O(2^n)$, 공간 복잡도는 $O(n)$이다**. + +![하노이의 탑 문제의 재귀 트리](hanota_problem.assets/hanota_recursive_tree.png){ class="animation-figure" } + +

그림 12-15   하노이의 탑 문제의 재귀 트리

+ +!!! quote "하노이의 탑 전설" + + 하노이의 탑 문제에는 오래된 전설이 전해진다. 고대 인도의 한 사원에서 승려들이 세 개의 높은 다이아몬드 기둥과 크기가 서로 다른 금 원판 $64$ 개를 가지고 있었다. 승려들은 원판을 계속 옮기며 마지막 원판이 올바른 위치에 놓이는 순간 세상이 끝난다고 믿었다. + + 하지만 매초 원판을 하나씩 옮겨도 총 $2^{64} \approx 1.84×10^{19}$ 초, 약 $5850$ 억 년이 필요하다. 이는 현재 추정하는 우주의 나이보다 훨씬 길다. 따라서 전설이 사실이라고 해도 세상의 종말을 걱정할 필요는 없을 것이다. diff --git a/ko/docs/chapter_divide_and_conquer/index.md b/ko/docs/chapter_divide_and_conquer/index.md new file mode 100644 index 000000000..5fa6ab849 --- /dev/null +++ b/ko/docs/chapter_divide_and_conquer/index.md @@ -0,0 +1,23 @@ +--- +comments: true +icon: material/set-split +--- + +# 제 12 장   분할 정복 + +![분할 정복](../assets/covers/chapter_divide_and_conquer.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 어려운 문제도 한 단계씩 나눌 때마다 더욱 단순해진다. + + 분할 정복은 중요한 사실을 알려 준다. 쉬운 것부터 시작하면 모든 것이 더 이상 복잡하지 않다. + +## 이 장의 내용 + +- [12.1   분할 정복 알고리즘](divide_and_conquer.md) +- [12.2   분할 정복 탐색 전략](binary_search_recur.md) +- [12.3   이진 트리 구성 문제](build_binary_tree_problem.md) +- [12.4   하노이의 탑 문제](hanota_problem.md) +- [12.5   요약](summary.md) +- [12.6   연습 문제](exercises.md) diff --git a/ko/docs/chapter_divide_and_conquer/summary.md b/ko/docs/chapter_divide_and_conquer/summary.md new file mode 100644 index 000000000..dfe7e4241 --- /dev/null +++ b/ko/docs/chapter_divide_and_conquer/summary.md @@ -0,0 +1,17 @@ +--- +comments: true +--- + +# 12.5   요약 + +### 1.   핵심 정리 + +- 분할 정복은 흔히 쓰이는 알고리즘 설계 전략이다. 분할(나누기)과 정복(합치기)의 두 단계로 이루어지며 보통 재귀로 구현한다. +- 분할 정복 문제를 판단하는 기준은 문제를 나눌 수 있는지, 부분 문제들이 독립적인지, 부분 문제의 해를 합칠 수 있는지다. +- 병합 정렬은 분할 정복 전략의 대표적인 응용이다. 배열을 같은 길이의 두 부분 배열로 재귀적으로 나누고, 원소가 하나만 남으면 단계적으로 병합하여 정렬한다. +- 분할 정복 전략을 도입하면 알고리즘 효율이 높아지는 경우가 많다. 연산 수를 줄일 수 있고, 시스템의 병렬 최적화에도 유리하기 때문이다. +- 분할 정복은 여러 알고리즘 문제를 해결할 뿐 아니라 자료 구조와 알고리즘의 설계에도 널리 사용되어 곳곳에서 찾아볼 수 있다. +- 적응형 탐색은 완전 탐색보다 효율적이다. 시간 복잡도가 $O(\log n)$인 탐색 알고리즘은 보통 분할 정복 전략을 기반으로 구현된다. +- 이진 탐색은 분할 정복 전략의 또 다른 대표적인 응용으로, 부분 문제의 해를 합치는 단계가 없다. 재귀적 분할 정복으로 이진 탐색을 구현할 수 있다. +- 이진 트리 구성 문제에서는 트리를 구성하는 원래 문제를 왼쪽 서브트리와 오른쪽 서브트리를 구성하는 부분 문제로 나눌 수 있다. 전위 순회와 중위 순회의 인덱스 구간을 나누어 구현한다. +- 하노이의 탑에서는 크기 $n$인 문제를 크기 $n-1$인 부분 문제 두 개와 크기 $1$인 부분 문제 하나로 나눌 수 있다. 세 부분 문제를 순서대로 해결하면 원래 문제도 해결된다. diff --git a/ko/docs/chapter_dynamic_programming/dp_problem_features.md b/ko/docs/chapter_dynamic_programming/dp_problem_features.md new file mode 100644 index 000000000..dc4c3509d --- /dev/null +++ b/ko/docs/chapter_dynamic_programming/dp_problem_features.md @@ -0,0 +1,958 @@ +--- +comments: true +--- + +# 14.2   동적 계획법 문제의 특성 + +앞 절에서는 부분 문제로 나누어 원래 문제를 푸는 동적 계획법을 배웠다. 부분 문제로 나누는 것은 일반적인 알고리즘 아이디어지만 분할 정복, 동적 계획법, 백트래킹에서는 강조점이 다르다. + +- 분할 정복은 원래 문제를 독립적인 여러 부분 문제로 재귀적으로 나누어 가장 작은 부분 문제까지 내려간다. 이후 되돌아오면서 해를 합쳐 원래 문제의 해를 얻는다. +- 동적 계획법도 문제를 재귀적으로 나누지만, 분할 정복과 달리 부분 문제들이 서로 의존하며 분해 과정에서 중복 부분 문제가 많이 생긴다. +- 백트래킹은 시도와 되돌리기로 가능한 모든 해를 열거하고 가지치기로 불필요한 탐색 가지를 피한다. 원래 문제의 해는 일련의 의사 결정 단계로 이루어지며, 각 결정 단계에 앞선 부분 수열을 부분 문제로 볼 수 있다. + +동적 계획법은 최적화 문제에 자주 쓰인다. 이러한 문제에는 중복 부분 문제뿐 아니라 최적 부분 구조와 무후효성이라는 두 가지 특성이 더 있다. + +## 14.2.1   최적 부분 구조 + +최적 부분 구조의 개념을 더 잘 보여 주도록 계단 오르기 문제를 조금 바꿔 보자. + +!!! question "계단 오르기의 최소 비용" + + 계단을 한 번에 $1$ 칸 또는 $2$ 칸 오를 수 있다. 각 칸에는 그곳에서 지불할 비용인 음이 아닌 정수가 적혀 있다. 음이 아닌 정수 배열 $cost$가 주어지며, $cost[i]$는 $i$ 번째 칸의 비용이고 $cost[0]$은 바닥(출발점)에 해당한다. 맨 위에 도달하는 데 필요한 최소 비용을 구하라. + +아래 그림(14-6)과 같이 $1$, $2$, $3$ 번째 칸의 비용이 각각 $1$, $10$, $1$이면 바닥에서 $3$ 번째 칸까지 오르는 최소 비용은 $2$다. + +![3번째 칸까지 오르는 최소 비용](dp_problem_features.assets/min_cost_cs_example.png){ class="animation-figure" } + +

그림 14-6   3번째 칸까지 오르는 최소 비용

+ +$i$ 번째 칸까지 오르는 데 지불한 누적 비용을 $dp[i]$라고 하자. $i$ 번째 칸에는 $i - 1$ 번째 또는 $i - 2$ 번째 칸에서만 올 수 있으므로 $dp[i]$는 $dp[i - 1] + cost[i]$ 또는 $dp[i - 2] + cost[i]$다. 비용을 최소화하려면 둘 중 작은 값을 선택해야 한다. + +$$ +dp[i] = \min(dp[i-1], dp[i-2]) + cost[i] +$$ + +여기서 최적 부분 구조의 의미를 알 수 있다. **원래 문제의 최적해는 부분 문제의 최적해로 구성된다**. + +이 문제는 분명히 최적 부분 구조를 가진다. 두 부분 문제의 최적해 $dp[i-1]$과 $dp[i-2]$ 중 더 좋은 것을 골라 원래 문제 $dp[i]$의 최적해를 구성하기 때문이다. + +그렇다면 앞 절의 계단 오르기 문제도 최적 부분 구조를 가질까? 방법 수를 구하는 것이 목표이므로 단순한 계수 문제처럼 보인다. 하지만 '최대 방법 수를 구하라'고 바꿔 보면 **문제의 의미는 같은데 최적 부분 구조가 드러난다**. $n$ 번째 칸의 최대 방법 수는 $n-1$ 번째와 $n-2$ 번째 칸의 최대 방법 수를 더한 값이다. 이처럼 최적 부분 구조의 해석은 유연하며 문제마다 다른 의미를 가질 수 있다. + +상태 전이 식과 초기 상태 $dp[1] = cost[1]$, $dp[2] = cost[2]$를 이용하면 다음 동적 계획법 코드를 얻는다. + +=== "Python" + + ```python title="min_cost_climbing_stairs_dp.py" + def min_cost_climbing_stairs_dp(cost: list[int]) -> int: + """계단 오르기의 최소 비용: 동적 계획법""" + n = len(cost) - 1 + if n == 1 or n == 2: + return cost[n] + # 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp = [0] * (n + 1) + # 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1], dp[2] = cost[1], cost[2] + # 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in range(3, n + 1): + dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i] + return dp[n] + ``` + +=== "C++" + + ```cpp title="min_cost_climbing_stairs_dp.cpp" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + int minCostClimbingStairsDP(vector &cost) { + int n = cost.size() - 1; + if (n == 1 || n == 2) + return cost[n]; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + vector dp(n + 1); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1]; + dp[2] = cost[2]; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i]; + } + return dp[n]; + } + ``` + +=== "Java" + + ```java title="min_cost_climbing_stairs_dp.java" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + int minCostClimbingStairsDP(int[] cost) { + int n = cost.length - 1; + if (n == 1 || n == 2) + return cost[n]; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + int[] dp = new int[n + 1]; + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1]; + dp[2] = cost[2]; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i]; + } + return dp[n]; + } + ``` + +=== "C#" + + ```csharp title="min_cost_climbing_stairs_dp.cs" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + int MinCostClimbingStairsDP(int[] cost) { + int n = cost.Length - 1; + if (n == 1 || n == 2) + return cost[n]; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + int[] dp = new int[n + 1]; + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1]; + dp[2] = cost[2]; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = Math.Min(dp[i - 1], dp[i - 2]) + cost[i]; + } + return dp[n]; + } + ``` + +=== "Go" + + ```go title="min_cost_climbing_stairs_dp.go" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + func minCostClimbingStairsDP(cost []int) int { + n := len(cost) - 1 + if n == 1 || n == 2 { + return cost[n] + } + min := func(a, b int) int { + if a < b { + return a + } + return b + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp := make([]int, n+1) + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1] + dp[2] = cost[2] + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i := 3; i <= n; i++ { + dp[i] = min(dp[i-1], dp[i-2]) + cost[i] + } + return dp[n] + } + ``` + +=== "Swift" + + ```swift title="min_cost_climbing_stairs_dp.swift" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + func minCostClimbingStairsDP(cost: [Int]) -> Int { + let n = cost.count - 1 + if n == 1 || n == 2 { + return cost[n] + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + var dp = Array(repeating: 0, count: n + 1) + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1] + dp[2] = cost[2] + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in 3 ... n { + dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i] + } + return dp[n] + } + ``` + +=== "JS" + + ```javascript title="min_cost_climbing_stairs_dp.js" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + function minCostClimbingStairsDP(cost) { + const n = cost.length - 1; + if (n === 1 || n === 2) { + return cost[n]; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + const dp = new Array(n + 1); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1]; + dp[2] = cost[2]; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (let i = 3; i <= n; i++) { + dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i]; + } + return dp[n]; + } + ``` + +=== "TS" + + ```typescript title="min_cost_climbing_stairs_dp.ts" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + function minCostClimbingStairsDP(cost: Array): number { + const n = cost.length - 1; + if (n === 1 || n === 2) { + return cost[n]; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + const dp = new Array(n + 1); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1]; + dp[2] = cost[2]; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (let i = 3; i <= n; i++) { + dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i]; + } + return dp[n]; + } + ``` + +=== "Dart" + + ```dart title="min_cost_climbing_stairs_dp.dart" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + int minCostClimbingStairsDP(List cost) { + int n = cost.length - 1; + if (n == 1 || n == 2) return cost[n]; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + List dp = List.filled(n + 1, 0); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1]; + dp[2] = cost[2]; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i]; + } + return dp[n]; + } + ``` + +=== "Rust" + + ```rust title="min_cost_climbing_stairs_dp.rs" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + fn min_cost_climbing_stairs_dp(cost: &[i32]) -> i32 { + let n = cost.len() - 1; + if n == 1 || n == 2 { + return cost[n]; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + let mut dp = vec![-1; n + 1]; + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1]; + dp[2] = cost[2]; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in 3..=n { + dp[i] = cmp::min(dp[i - 1], dp[i - 2]) + cost[i]; + } + dp[n] + } + ``` + +=== "C" + + ```c title="min_cost_climbing_stairs_dp.c" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + int minCostClimbingStairsDP(int cost[], int costSize) { + int n = costSize - 1; + if (n == 1 || n == 2) + return cost[n]; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + int *dp = calloc(n + 1, sizeof(int)); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1]; + dp[2] = cost[2]; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = myMin(dp[i - 1], dp[i - 2]) + cost[i]; + } + int res = dp[n]; + // 메모리 해제 + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="min_cost_climbing_stairs_dp.kt" + /* 계단 오르기의 최소 비용: 동적 계획법 */ + fun minCostClimbingStairsDP(cost: IntArray): Int { + val n = cost.size - 1 + if (n == 1 || n == 2) return cost[n] + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + val dp = IntArray(n + 1) + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = cost[1] + dp[2] = cost[2] + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (i in 3..n) { + dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i] + } + return dp[n] + } + ``` + +=== "Ruby" + + ```ruby title="min_cost_climbing_stairs_dp.rb" + ### 계단 오르기의 최소 비용: 동적 계획법 ### + def min_cost_climbing_stairs_dp(cost) + n = cost.length - 1 + return cost[n] if n == 1 || n == 2 + # 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp = Array.new(n + 1, 0) + # 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1], dp[2] = cost[1], cost[2] + # 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + (3...(n + 1)).each { |i| dp[i] = [dp[i - 1], dp[i - 2]].min + cost[i] } + dp[n] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-7)은 위 코드의 동적 계획법 과정이다. + +![계단 오르기 최소 비용의 동적 계획법 과정](dp_problem_features.assets/min_cost_cs_dp.png){ class="animation-figure" } + +

그림 14-7   계단 오르기 최소 비용의 동적 계획법 과정

+ +이 문제도 공간을 최적화하여 1차원을 0차원으로 압축하고, 공간 복잡도를 $O(n)$에서 $O(1)$로 줄일 수 있다. + +=== "Python" + + ```python title="min_cost_climbing_stairs_dp.py" + def min_cost_climbing_stairs_dp_comp(cost: list[int]) -> int: + """계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법""" + n = len(cost) - 1 + if n == 1 or n == 2: + return cost[n] + a, b = cost[1], cost[2] + for i in range(3, n + 1): + a, b = b, min(a, b) + cost[i] + return b + ``` + +=== "C++" + + ```cpp title="min_cost_climbing_stairs_dp.cpp" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + int minCostClimbingStairsDPComp(vector &cost) { + int n = cost.size() - 1; + if (n == 1 || n == 2) + return cost[n]; + int a = cost[1], b = cost[2]; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = min(a, tmp) + cost[i]; + a = tmp; + } + return b; + } + ``` + +=== "Java" + + ```java title="min_cost_climbing_stairs_dp.java" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + int minCostClimbingStairsDPComp(int[] cost) { + int n = cost.length - 1; + if (n == 1 || n == 2) + return cost[n]; + int a = cost[1], b = cost[2]; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = Math.min(a, tmp) + cost[i]; + a = tmp; + } + return b; + } + ``` + +=== "C#" + + ```csharp title="min_cost_climbing_stairs_dp.cs" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + int MinCostClimbingStairsDPComp(int[] cost) { + int n = cost.Length - 1; + if (n == 1 || n == 2) + return cost[n]; + int a = cost[1], b = cost[2]; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = Math.Min(a, tmp) + cost[i]; + a = tmp; + } + return b; + } + ``` + +=== "Go" + + ```go title="min_cost_climbing_stairs_dp.go" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + func minCostClimbingStairsDPComp(cost []int) int { + n := len(cost) - 1 + if n == 1 || n == 2 { + return cost[n] + } + min := func(a, b int) int { + if a < b { + return a + } + return b + } + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + a, b := cost[1], cost[2] + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i := 3; i <= n; i++ { + tmp := b + b = min(a, tmp) + cost[i] + a = tmp + } + return b + } + ``` + +=== "Swift" + + ```swift title="min_cost_climbing_stairs_dp.swift" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + func minCostClimbingStairsDPComp(cost: [Int]) -> Int { + let n = cost.count - 1 + if n == 1 || n == 2 { + return cost[n] + } + var (a, b) = (cost[1], cost[2]) + for i in 3 ... n { + (a, b) = (b, min(a, b) + cost[i]) + } + return b + } + ``` + +=== "JS" + + ```javascript title="min_cost_climbing_stairs_dp.js" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + function minCostClimbingStairsDPComp(cost) { + const n = cost.length - 1; + if (n === 1 || n === 2) { + return cost[n]; + } + let a = cost[1], + b = cost[2]; + for (let i = 3; i <= n; i++) { + const tmp = b; + b = Math.min(a, tmp) + cost[i]; + a = tmp; + } + return b; + } + ``` + +=== "TS" + + ```typescript title="min_cost_climbing_stairs_dp.ts" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + function minCostClimbingStairsDPComp(cost: Array): number { + const n = cost.length - 1; + if (n === 1 || n === 2) { + return cost[n]; + } + let a = cost[1], + b = cost[2]; + for (let i = 3; i <= n; i++) { + const tmp = b; + b = Math.min(a, tmp) + cost[i]; + a = tmp; + } + return b; + } + ``` + +=== "Dart" + + ```dart title="min_cost_climbing_stairs_dp.dart" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + int minCostClimbingStairsDPComp(List cost) { + int n = cost.length - 1; + if (n == 1 || n == 2) return cost[n]; + int a = cost[1], b = cost[2]; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = min(a, tmp) + cost[i]; + a = tmp; + } + return b; + } + ``` + +=== "Rust" + + ```rust title="min_cost_climbing_stairs_dp.rs" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + fn min_cost_climbing_stairs_dp_comp(cost: &[i32]) -> i32 { + let n = cost.len() - 1; + if n == 1 || n == 2 { + return cost[n]; + }; + let (mut a, mut b) = (cost[1], cost[2]); + for i in 3..=n { + let tmp = b; + b = cmp::min(a, tmp) + cost[i]; + a = tmp; + } + b + } + ``` + +=== "C" + + ```c title="min_cost_climbing_stairs_dp.c" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + int minCostClimbingStairsDPComp(int cost[], int costSize) { + int n = costSize - 1; + if (n == 1 || n == 2) + return cost[n]; + int a = cost[1], b = cost[2]; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = myMin(a, tmp) + cost[i]; + a = tmp; + } + return b; + } + ``` + +=== "Kotlin" + + ```kotlin title="min_cost_climbing_stairs_dp.kt" + /* 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 */ + fun minCostClimbingStairsDPComp(cost: IntArray): Int { + val n = cost.size - 1 + if (n == 1 || n == 2) return cost[n] + var a = cost[1] + var b = cost[2] + for (i in 3..n) { + val tmp = b + b = min(a, tmp) + cost[i] + a = tmp + } + return b + } + ``` + +=== "Ruby" + + ```ruby title="min_cost_climbing_stairs_dp.rb" + ### 계단 오르기의 최소 비용: 동적 계획법 ### + def min_cost_climbing_stairs_dp(cost) + n = cost.length - 1 + return cost[n] if n == 1 || n == 2 + # 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp = Array.new(n + 1, 0) + # 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1], dp[2] = cost[1], cost[2] + # 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + (3...(n + 1)).each { |i| dp[i] = [dp[i - 1], dp[i - 2]].min + cost[i] } + dp[n] + end + + # 계단 오르기의 최소 비용: 공간을 최적화한 동적 계획법 + def min_cost_climbing_stairs_dp_comp(cost) + n = cost.length - 1 + return cost[n] if n == 1 || n == 2 + a, b = cost[1], cost[2] + (3...(n + 1)).each { |i| a, b = b, [a, b].min + cost[i] } + b + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 14.2.2   무후효성 + +무후효성은 동적 계획법이 문제를 효과적으로 해결할 수 있게 하는 중요한 특성이다. **어떤 상태가 주어졌을 때 이후의 전개가 현재 상태에만 의존하고, 과거에 거쳐 온 모든 상태와는 무관하다**는 뜻이다. + +계단 오르기에서 상태 $i$는 $1$ 칸 또는 $2$ 칸 오르는 선택에 따라 상태 $i+1$ 또는 $i+2$로 이어진다. 두 선택을 할 때 상태 $i$ 이전의 상태를 고려할 필요는 없다. 과거 상태는 $i$ 이후의 전개에 영향을 주지 않기 때문이다. + +하지만 계단 오르기에 제약 하나를 추가하면 상황이 달라진다. + +!!! question "제약이 있는 계단 오르기" + + 총 $n$ 칸의 계단을 한 번에 $1$ 칸 또는 $2$ 칸 오를 수 있다. **단, 연속 두 번 $1$ 칸씩 오를 수는 없다**. 맨 위까지 오르는 방법은 몇 가지인가? + +아래 그림(14-8)에서는 $3$ 번째 칸까지 오르는 유효한 방법이 $2$ 가지만 남는다. $1$ 칸씩 세 번 연속 오르는 방법은 제약 조건을 만족하지 않아 제외된다. + +![제약이 있을 때 3번째 칸까지 오르는 방법 수](dp_problem_features.assets/climbing_stairs_constraint_example.png){ class="animation-figure" } + +

그림 14-8   제약이 있을 때 3번째 칸까지 오르는 방법 수

+ +이 문제에서 직전에 $1$ 칸 올라왔다면 다음에는 반드시 $2$ 칸 올라야 한다. 즉, **다음 선택은 현재 상태(현재 칸 번호)만으로 정해지지 않고 이전 상태(직전에 있던 칸 번호)에도 의존한다**. + +따라서 기존 상태 정의로는 무후효성을 만족하지 않으며, 상태 전이 식 $dp[i] = dp[i-1] + dp[i-2]$도 더 이상 유효하지 않다. $dp[i-1]$은 이번에 $1$ 칸 오르는 경우인데, 그 안에는 '직전에도 $1$ 칸 올라온' 방법이 많이 들어 있다. 제약을 만족하려면 $dp[i-1]$을 그대로 $dp[i]$에 더할 수 없다. + +이를 위해 상태 정의를 확장해야 한다. **상태 $[i, j]$는 현재 $i$ 번째 칸에 있고 직전에 $j$ 칸 올라왔음을 나타내며**, $j \in \{1, 2\}$이다. 이렇게 정의하면 직전에 $1$ 칸 왔는지 $2$ 칸 왔는지를 구분하여 현재 상태의 이전 상태를 판단할 수 있다. + +- 직전에 $1$ 칸 왔다면 그 앞에서는 $2$ 칸만 올 수 있다. 즉, $dp[i, 1]$은 $dp[i-1, 2]$에서만 전이된다. +- 직전에 $2$ 칸 왔다면 그 앞에서는 $1$ 칸이나 $2$ 칸 모두 가능하다. 즉, $dp[i, 2]$는 $dp[i-2, 1]$ 또는 $dp[i-2, 2]$에서 전이된다. + +아래 그림(14-9)과 같이 이 정의에서 $dp[i, j]$는 상태 $[i, j]$의 방법 수를 나타낸다. 상태 전이 식은 다음과 같다. + +$$ +\begin{cases} +dp[i, 1] = dp[i-1, 2] \\ +dp[i, 2] = dp[i-2, 1] + dp[i-2, 2] +\end{cases} +$$ + +![제약을 고려한 점화 관계](dp_problem_features.assets/climbing_stairs_constraint_state_transfer.png){ class="animation-figure" } + +

그림 14-9   제약을 고려한 점화 관계

+ +마지막으로 $dp[n, 1] + dp[n, 2]$를 반환하면 된다. 두 값의 합은 $n$ 번째 칸까지 오르는 전체 방법 수다. + +=== "Python" + + ```python title="climbing_stairs_constraint_dp.py" + def climbing_stairs_constraint_dp(n: int) -> int: + """제약 조건이 있는 계단 오르기: 동적 계획법""" + if n == 1 or n == 2: + return 1 + # 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp = [[0] * 3 for _ in range(n + 1)] + # 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1], dp[1][2] = 1, 0 + dp[2][1], dp[2][2] = 0, 1 + # 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in range(3, n + 1): + dp[i][1] = dp[i - 1][2] + dp[i][2] = dp[i - 2][1] + dp[i - 2][2] + return dp[n][1] + dp[n][2] + ``` + +=== "C++" + + ```cpp title="climbing_stairs_constraint_dp.cpp" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + int climbingStairsConstraintDP(int n) { + if (n == 1 || n == 2) { + return 1; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + vector> dp(n + 1, vector(3, 0)); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1; + dp[1][2] = 0; + dp[2][1] = 0; + dp[2][2] = 1; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i][1] = dp[i - 1][2]; + dp[i][2] = dp[i - 2][1] + dp[i - 2][2]; + } + return dp[n][1] + dp[n][2]; + } + ``` + +=== "Java" + + ```java title="climbing_stairs_constraint_dp.java" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + int climbingStairsConstraintDP(int n) { + if (n == 1 || n == 2) { + return 1; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + int[][] dp = new int[n + 1][3]; + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1; + dp[1][2] = 0; + dp[2][1] = 0; + dp[2][2] = 1; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i][1] = dp[i - 1][2]; + dp[i][2] = dp[i - 2][1] + dp[i - 2][2]; + } + return dp[n][1] + dp[n][2]; + } + ``` + +=== "C#" + + ```csharp title="climbing_stairs_constraint_dp.cs" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + int ClimbingStairsConstraintDP(int n) { + if (n == 1 || n == 2) { + return 1; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + int[,] dp = new int[n + 1, 3]; + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1, 1] = 1; + dp[1, 2] = 0; + dp[2, 1] = 0; + dp[2, 2] = 1; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i, 1] = dp[i - 1, 2]; + dp[i, 2] = dp[i - 2, 1] + dp[i - 2, 2]; + } + return dp[n, 1] + dp[n, 2]; + } + ``` + +=== "Go" + + ```go title="climbing_stairs_constraint_dp.go" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + func climbingStairsConstraintDP(n int) int { + if n == 1 || n == 2 { + return 1 + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp := make([][3]int, n+1) + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1 + dp[1][2] = 0 + dp[2][1] = 0 + dp[2][2] = 1 + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i := 3; i <= n; i++ { + dp[i][1] = dp[i-1][2] + dp[i][2] = dp[i-2][1] + dp[i-2][2] + } + return dp[n][1] + dp[n][2] + } + ``` + +=== "Swift" + + ```swift title="climbing_stairs_constraint_dp.swift" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + func climbingStairsConstraintDP(n: Int) -> Int { + if n == 1 || n == 2 { + return 1 + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + var dp = Array(repeating: Array(repeating: 0, count: 3), count: n + 1) + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1 + dp[1][2] = 0 + dp[2][1] = 0 + dp[2][2] = 1 + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in 3 ... n { + dp[i][1] = dp[i - 1][2] + dp[i][2] = dp[i - 2][1] + dp[i - 2][2] + } + return dp[n][1] + dp[n][2] + } + ``` + +=== "JS" + + ```javascript title="climbing_stairs_constraint_dp.js" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + function climbingStairsConstraintDP(n) { + if (n === 1 || n === 2) { + return 1; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + const dp = Array.from(new Array(n + 1), () => new Array(3)); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1; + dp[1][2] = 0; + dp[2][1] = 0; + dp[2][2] = 1; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (let i = 3; i <= n; i++) { + dp[i][1] = dp[i - 1][2]; + dp[i][2] = dp[i - 2][1] + dp[i - 2][2]; + } + return dp[n][1] + dp[n][2]; + } + ``` + +=== "TS" + + ```typescript title="climbing_stairs_constraint_dp.ts" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + function climbingStairsConstraintDP(n: number): number { + if (n === 1 || n === 2) { + return 1; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + const dp = Array.from({ length: n + 1 }, () => new Array(3)); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1; + dp[1][2] = 0; + dp[2][1] = 0; + dp[2][2] = 1; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (let i = 3; i <= n; i++) { + dp[i][1] = dp[i - 1][2]; + dp[i][2] = dp[i - 2][1] + dp[i - 2][2]; + } + return dp[n][1] + dp[n][2]; + } + ``` + +=== "Dart" + + ```dart title="climbing_stairs_constraint_dp.dart" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + int climbingStairsConstraintDP(int n) { + if (n == 1 || n == 2) { + return 1; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + List> dp = List.generate(n + 1, (index) => List.filled(3, 0)); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1; + dp[1][2] = 0; + dp[2][1] = 0; + dp[2][2] = 1; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i][1] = dp[i - 1][2]; + dp[i][2] = dp[i - 2][1] + dp[i - 2][2]; + } + return dp[n][1] + dp[n][2]; + } + ``` + +=== "Rust" + + ```rust title="climbing_stairs_constraint_dp.rs" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + fn climbing_stairs_constraint_dp(n: usize) -> i32 { + if n == 1 || n == 2 { + return 1; + }; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + let mut dp = vec![vec![-1; 3]; n + 1]; + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1; + dp[1][2] = 0; + dp[2][1] = 0; + dp[2][2] = 1; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in 3..=n { + dp[i][1] = dp[i - 1][2]; + dp[i][2] = dp[i - 2][1] + dp[i - 2][2]; + } + dp[n][1] + dp[n][2] + } + ``` + +=== "C" + + ```c title="climbing_stairs_constraint_dp.c" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + int climbingStairsConstraintDP(int n) { + if (n == 1 || n == 2) { + return 1; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + int **dp = malloc((n + 1) * sizeof(int *)); + for (int i = 0; i <= n; i++) { + dp[i] = calloc(3, sizeof(int)); + } + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1; + dp[1][2] = 0; + dp[2][1] = 0; + dp[2][2] = 1; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i][1] = dp[i - 1][2]; + dp[i][2] = dp[i - 2][1] + dp[i - 2][2]; + } + int res = dp[n][1] + dp[n][2]; + // 메모리 해제 + for (int i = 0; i <= n; i++) { + free(dp[i]); + } + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="climbing_stairs_constraint_dp.kt" + /* 제약 조건이 있는 계단 오르기: 동적 계획법 */ + fun climbingStairsConstraintDP(n: Int): Int { + if (n == 1 || n == 2) { + return 1 + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + val dp = Array(n + 1) { IntArray(3) } + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1] = 1 + dp[1][2] = 0 + dp[2][1] = 0 + dp[2][2] = 1 + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (i in 3..n) { + dp[i][1] = dp[i - 1][2] + dp[i][2] = dp[i - 2][1] + dp[i - 2][2] + } + return dp[n][1] + dp[n][2] + } + ``` + +=== "Ruby" + + ```ruby title="climbing_stairs_constraint_dp.rb" + ### 제약 조건이 있는 계단 오르기: 동적 계획법 ### + def climbing_stairs_constraint_dp(n) + return 1 if n == 1 || n == 2 + + # 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp = Array.new(n + 1) { Array.new(3, 0) } + # 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1][1], dp[1][2] = 1, 0 + dp[2][1], dp[2][2] = 0, 1 + # 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in 3...(n + 1) + dp[i][1] = dp[i - 1][2] + dp[i][2] = dp[i - 2][1] + dp[i - 2][2] + end + + dp[n][1] + dp[n][2] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +위 예시는 이전 상태 하나만 추가로 고려하면 되므로 상태 정의를 확장하여 무후효성을 다시 만족시킬 수 있었다. 하지만 일부 문제는 '과거의 영향'이 매우 강하다. + +!!! question "계단 오르기와 장애물 생성" + + 총 $n$ 칸의 계단을 한 번에 $1$ 칸 또는 $2$ 칸 오를 수 있다. **$i$ 번째 칸에 도착하면 시스템이 자동으로 $2i$ 번째 칸에 장애물을 놓으며, 이후의 모든 이동에서는 $2i$ 번째 칸으로 갈 수 없다**. 예를 들어 처음 두 번에 $2$, $3$ 번째 칸에 도착했다면 이후에는 $4$, $6$ 번째 칸에 갈 수 없다. 맨 위까지 오르는 방법은 몇 가지인가? + +이 문제에서 다음 이동은 과거의 모든 상태에 의존한다. 매번 이동할 때마다 더 높은 칸에 장애물이 생겨 이후 이동에 영향을 주기 때문이다. 이런 문제는 동적 계획법으로 해결하기 어려운 경우가 많다. + +외판원 문제 같은 복잡한 조합 최적화 문제 중에는 무후효성을 만족하지 않는 경우가 많다. 이런 문제에는 보통 휴리스틱 탐색, 유전 알고리즘, 강화 학습 등의 다른 방법을 사용하여 제한된 시간 안에 활용 가능한 국소 최적해를 구한다. diff --git a/ko/docs/chapter_dynamic_programming/dp_solution_pipeline.md b/ko/docs/chapter_dynamic_programming/dp_solution_pipeline.md new file mode 100644 index 000000000..2c425cc24 --- /dev/null +++ b/ko/docs/chapter_dynamic_programming/dp_solution_pipeline.md @@ -0,0 +1,1511 @@ +--- +comments: true +--- + +# 14.3   동적 계획법 문제 풀이 방법 + +앞의 두 절에서는 동적 계획법 문제의 주요 특성을 살펴보았다. 이제 더 실용적인 두 질문을 알아보자. + +1. 어떤 문제가 동적 계획법 문제인지 어떻게 판단할까? +2. 동적 계획법 문제는 어디서 시작하며, 전체 풀이 단계는 어떻게 될까? + +## 14.3.1   문제 판단 + +대체로 중복 부분 문제와 최적 부분 구조가 있고 무후효성을 만족하면 동적 계획법에 적합하다. 하지만 문제 설명에서 이러한 특성을 바로 끌어내기는 어렵다. 따라서 보통 조건을 완화하여 **먼저 백트래킹(모든 경우 열거)으로 풀기에 적합한지 살펴본다**. + +**백트래킹에 적합한 문제는 보통 '의사 결정 트리 모형'을 따른다**. 각 노드가 결정 하나를, 각 경로가 결정의 순서를 나타내는 트리 구조로 표현할 수 있는 문제다. + +즉, 명확한 결정 개념이 있고 연속된 결정을 통해 해가 만들어진다면 의사 결정 트리 모형을 만족하며, 보통 백트래킹으로 해결할 수 있다. + +여기에 더해 동적 계획법 문제로 판단할 때 '가산점'이 되는 요소도 있다. + +- 최댓값, 최솟값, 가장 많은 수, 가장 적은 수 등의 최적화 표현이 포함되어 있다. +- 상태를 리스트, 다차원 행렬, 트리로 표현할 수 있고, 한 상태와 주변 상태들 사이에 점화 관계가 있다. + +반대로 '감점' 요소도 있다. + +- 최적해 하나가 아니라 가능한 모든 해결 방법을 찾는 것이 목표다. +- 문제 설명에 순열이나 조합의 특성이 뚜렷하며, 구체적인 여러 방법을 반환해야 한다. + +의사 결정 트리 모형을 만족하면서 '가산점' 요소가 뚜렷하다면, 우선 동적 계획법 문제라고 가정하고 풀이 과정에서 확인해 볼 수 있다. + +## 14.3.2   문제 해결 단계 + +동적 계획법의 풀이 흐름은 문제의 성격과 난이도에 따라 다르지만, 보통 결정 설명, 상태 정의, $dp$ 테이블 구성, 상태 전이 식 도출, 경계 조건 결정 등의 단계를 따른다. + +단계를 더 구체적으로 보여 주기 위해 대표적인 '최소 경로 합' 문제를 예로 든다. + +!!! question "문제" + + $n \times m$의 2차원 격자 `grid`가 주어진다. 각 칸에는 그 칸의 비용인 음이 아닌 정수가 들어 있다. 로봇은 왼쪽 위에서 출발하여 매번 아래 또는 오른쪽으로 한 칸씩만 이동하고, 오른쪽 아래에 도착한다. 왼쪽 위에서 오른쪽 아래까지의 최소 경로 합을 반환하라. + +아래 그림(14-10)의 격자에서는 최소 경로 합이 $13$이다. + +![최소 경로 합 예시 데이터](dp_solution_pipeline.assets/min_path_sum_example.png){ class="animation-figure" } + +

그림 14-10   최소 경로 합 예시 데이터

+ +**1단계: 매 차례의 결정을 생각하고 상태를 정의하여 $dp$ 테이블을 얻는다** + +이 문제에서 매 차례의 결정은 현재 칸에서 아래 또는 오른쪽으로 한 칸 이동하는 것이다. 현재 칸의 행과 열 인덱스가 $[i, j]$라면 이동 후에는 $[i+1, j]$ 또는 $[i, j+1]$이 된다. 따라서 상태는 행 인덱스와 열 인덱스라는 두 변수를 포함해야 하며 $[i, j]$로 표시한다. + +상태 $[i, j]$의 부분 문제는 출발점 $[0, 0]$에서 $[i, j]$까지의 최소 경로 합이고, 해를 $dp[i, j]$라고 한다. + +이로써 아래 그림(14-11)처럼 입력 격자 $grid$와 크기가 같은 2차원 $dp$ 행렬을 얻는다. + +![상태 정의와 dp 테이블](dp_solution_pipeline.assets/min_path_sum_solution_state_definition.png){ class="animation-figure" } + +

그림 14-11   상태 정의와 dp 테이블

+ +!!! note "참고" + + 동적 계획법과 백트래킹 과정은 결정의 순서로 표현할 수 있으며, 상태는 모든 결정 변수로 이루어진다. 상태에는 풀이의 진행 정도를 나타내는 모든 변수가 포함되어야 한다. 즉, 다음 상태를 도출하기에 충분한 정보가 있어야 한다. + + 각 상태는 부분 문제 하나에 대응하며, 모든 부분 문제의 해를 저장하기 위해 $dp$ 테이블을 정의한다. 상태의 독립 변수 각각이 $dp$ 테이블의 한 차원이 된다. 본질적으로 $dp$ 테이블은 상태와 부분 문제의 해 사이의 매핑이다. + +**2단계: 최적 부분 구조를 찾아 상태 전이 식을 도출한다** + +상태 $[i, j]$는 위쪽 칸 $[i-1, j]$ 또는 왼쪽 칸 $[i, j-1]$에서만 올 수 있다. 따라서 최적 부분 구조는 다음과 같다. $[i, j]$까지의 최소 경로 합은 $[i, j-1]$까지의 최소 경로 합과 $[i-1, j]$까지의 최소 경로 합 중 작은 쪽에 의해 결정된다. + +위 분석으로 아래 그림(14-12)의 상태 전이 식을 얻는다. + +$$ +dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j] +$$ + +![최적 부분 구조와 상태 전이 식](dp_solution_pipeline.assets/min_path_sum_solution_state_transition.png){ class="animation-figure" } + +

그림 14-12   최적 부분 구조와 상태 전이 식

+ +!!! note "참고" + + 정의한 $dp$ 테이블을 바탕으로 원래 문제와 부분 문제의 관계를 생각하고, 부분 문제의 최적해로 원래 문제의 최적해를 만드는 방법, 즉 최적 부분 구조를 찾는다. + + 최적 부분 구조를 찾으면 이를 이용해 상태 전이 식을 세울 수 있다. + +**3단계: 경계 조건과 상태 전이 순서를 정한다** + +이 문제에서 첫 행의 상태는 왼쪽 상태에서만, 첫 열의 상태는 위쪽 상태에서만 올 수 있다. 따라서 첫 행 $i = 0$과 첫 열 $j = 0$이 경계 조건이다. + +아래 그림(14-13)과 같이 각 칸은 왼쪽 칸과 위쪽 칸에서 전이되므로, 바깥 반복문은 행을, 안쪽 반복문은 열을 순회한다. + +![경계 조건과 상태 전이 순서](dp_solution_pipeline.assets/min_path_sum_solution_initial_state.png){ class="animation-figure" } + +

그림 14-13   경계 조건과 상태 전이 순서

+ +!!! note "참고" + + 경계 조건은 동적 계획법에서 $dp$ 테이블을 초기화하는 데, 탐색에서는 가지치기에 사용한다. + + 상태 전이 순서의 핵심은 현재 문제의 해를 계산하기 전에 그 해가 의존하는 모든 작은 부분 문제의 해가 올바르게 계산되어 있도록 하는 것이다. + +이제 동적 계획법 코드를 바로 작성할 수 있다. 하지만 부분 문제로 나누는 사고는 위에서 아래로 진행하므로, '완전 탐색 $\rightarrow$ 메모이제이션을 이용한 탐색 $\rightarrow$ 동적 계획법' 순서로 구현하는 편이 사고 흐름에 더 자연스럽다. + +### 1.   방법 1: 완전 탐색 + +상태 $[i, j]$에서 탐색을 시작하여 더 작은 상태 $[i-1, j]$와 $[i, j-1]$로 계속 나눈다. 재귀 함수에는 다음 요소가 포함된다. + +- **재귀 매개변수**: 상태 $[i, j]$. +- **반환값**: $[0, 0]$에서 $[i, j]$까지의 최소 경로 합 $dp[i, j]$. +- **종료 조건**: $i = 0$이고 $j = 0$이면 비용 $grid[0, 0]$을 반환한다. +- **가지치기**: $i < 0$ 또는 $j < 0$이면 인덱스가 범위를 벗어나므로 불가능함을 나타내는 비용 $+\infty$를 반환한다. + +구현 코드는 다음과 같다. + +=== "Python" + + ```python title="min_path_sum.py" + def min_path_sum_dfs(grid: list[list[int]], i: int, j: int) -> int: + """최소 경로 합: 완전 탐색""" + # 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if i == 0 and j == 0: + return grid[0][0] + # 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if i < 0 or j < 0: + return inf + # 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + up = min_path_sum_dfs(grid, i - 1, j) + left = min_path_sum_dfs(grid, i, j - 1) + # 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return min(left, up) + grid[i][j] + ``` + +=== "C++" + + ```cpp title="min_path_sum.cpp" + /* 최소 경로 합: 완전 탐색 */ + int minPathSumDFS(vector> &grid, int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return INT_MAX; + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + int up = minPathSumDFS(grid, i - 1, j); + int left = minPathSumDFS(grid, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return min(left, up) != INT_MAX ? min(left, up) + grid[i][j] : INT_MAX; + } + ``` + +=== "Java" + + ```java title="min_path_sum.java" + /* 최소 경로 합: 완전 탐색 */ + int minPathSumDFS(int[][] grid, int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return Integer.MAX_VALUE; + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + int up = minPathSumDFS(grid, i - 1, j); + int left = minPathSumDFS(grid, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return Math.min(left, up) + grid[i][j]; + } + ``` + +=== "C#" + + ```csharp title="min_path_sum.cs" + /* 최소 경로 합: 완전 탐색 */ + int MinPathSumDFS(int[][] grid, int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return int.MaxValue; + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + int up = MinPathSumDFS(grid, i - 1, j); + int left = MinPathSumDFS(grid, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return Math.Min(left, up) + grid[i][j]; + } + ``` + +=== "Go" + + ```go title="min_path_sum.go" + /* 최소 경로 합: 완전 탐색 */ + func minPathSumDFS(grid [][]int, i, j int) int { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if i == 0 && j == 0 { + return grid[0][0] + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if i < 0 || j < 0 { + return math.MaxInt + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + up := minPathSumDFS(grid, i-1, j) + left := minPathSumDFS(grid, i, j-1) + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return int(math.Min(float64(left), float64(up))) + grid[i][j] + } + ``` + +=== "Swift" + + ```swift title="min_path_sum.swift" + /* 최소 경로 합: 완전 탐색 */ + func minPathSumDFS(grid: [[Int]], i: Int, j: Int) -> Int { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if i == 0, j == 0 { + return grid[0][0] + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if i < 0 || j < 0 { + return .max + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + let up = minPathSumDFS(grid: grid, i: i - 1, j: j) + let left = minPathSumDFS(grid: grid, i: i, j: j - 1) + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return min(left, up) + grid[i][j] + } + ``` + +=== "JS" + + ```javascript title="min_path_sum.js" + /* 최소 경로 합: 완전 탐색 */ + function minPathSumDFS(grid, i, j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i === 0 && j === 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return Infinity; + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + const up = minPathSumDFS(grid, i - 1, j); + const left = minPathSumDFS(grid, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return Math.min(left, up) + grid[i][j]; + } + ``` + +=== "TS" + + ```typescript title="min_path_sum.ts" + /* 최소 경로 합: 완전 탐색 */ + function minPathSumDFS( + grid: Array>, + i: number, + j: number + ): number { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i === 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return Infinity; + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + const up = minPathSumDFS(grid, i - 1, j); + const left = minPathSumDFS(grid, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return Math.min(left, up) + grid[i][j]; + } + ``` + +=== "Dart" + + ```dart title="min_path_sum.dart" + /* 최소 경로 합: 완전 탐색 */ + int minPathSumDFS(List> grid, int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + // Dart의 int 타입은 범위가 고정된 정수이며, 무한대를 나타내는 값이 없다 + return BigInt.from(2).pow(31).toInt(); + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + int up = minPathSumDFS(grid, i - 1, j); + int left = minPathSumDFS(grid, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return min(left, up) + grid[i][j]; + } + ``` + +=== "Rust" + + ```rust title="min_path_sum.rs" + /* 최소 경로 합: 완전 탐색 */ + fn min_path_sum_dfs(grid: &Vec>, i: i32, j: i32) -> i32 { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if i == 0 && j == 0 { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if i < 0 || j < 0 { + return i32::MAX; + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + let up = min_path_sum_dfs(grid, i - 1, j); + let left = min_path_sum_dfs(grid, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + std::cmp::min(left, up) + grid[i as usize][j as usize] + } + ``` + +=== "C" + + ```c title="min_path_sum.c" + /* 최소 경로 합: 완전 탐색 */ + int minPathSumDFS(int grid[MAX_SIZE][MAX_SIZE], int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return INT_MAX; + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + int up = minPathSumDFS(grid, i - 1, j); + int left = minPathSumDFS(grid, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return myMin(left, up) != INT_MAX ? myMin(left, up) + grid[i][j] : INT_MAX; + } + ``` + +=== "Kotlin" + + ```kotlin title="min_path_sum.kt" + /* 최소 경로 합: 완전 탐색 */ + fun minPathSumDFS(grid: Array, i: Int, j: Int): Int { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0] + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return Int.MAX_VALUE + } + // 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + val up = minPathSumDFS(grid, i - 1, j) + val left = minPathSumDFS(grid, i, j - 1) + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + return min(left, up) + grid[i][j] + } + ``` + +=== "Ruby" + + ```ruby title="min_path_sum.rb" + ### 최소 경로 합: 완전 탐색 ### + def min_path_sum_dfs(grid, i, j) + # 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + return grid[i][j] if i == 0 && j == 0 + # 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + return Float::INFINITY if i < 0 || j < 0 + # 왼쪽 위 모서리에서 (i-1, j)와 (i, j-1)까지의 최소 경로 비용을 계산한다 + up = min_path_sum_dfs(grid, i - 1, j) + left = min_path_sum_dfs(grid, i, j - 1) + # 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 반환한다 + [left, up].min + grid[i][j] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-14)은 $dp[2, 1]$을 루트로 하는 재귀 트리다. 중복 부분 문제가 포함되어 있으며, 그 수는 격자 `grid`의 크기가 커질수록 급격히 늘어난다. + +중복 부분 문제가 생기는 근본적인 이유는 **왼쪽 위에서 어떤 칸에 도달하는 경로가 여러 개 있기 때문**이다. + +![완전 탐색의 재귀 트리](dp_solution_pipeline.assets/min_path_sum_dfs.png){ class="animation-figure" } + +

그림 14-14   완전 탐색의 재귀 트리

+ +각 상태에는 아래와 오른쪽이라는 두 선택이 있고, 왼쪽 위에서 오른쪽 아래까지는 총 $m + n - 2$ 번 이동해야 한다. 따라서 최악의 시간 복잡도는 $O(2^{m + n})$이며, $n$과 $m$은 각각 격자의 행 수와 열 수다. 이 계산은 경계 근처의 상황을 고려하지 않았다. 격자 경계에서는 선택이 하나뿐이므로 실제 경로 수는 이보다 적다. + +### 2.   방법 2: 메모이제이션을 이용한 탐색 + +격자 `grid`와 같은 크기의 메모이제이션 리스트 `mem`을 도입하여 각 부분 문제의 해를 기록하고 중복 부분 문제를 가지치기한다. + +=== "Python" + + ```python title="min_path_sum.py" + def min_path_sum_dfs_mem( + grid: list[list[int]], mem: list[list[int]], i: int, j: int + ) -> int: + """최소 경로 합: 메모이제이션을 이용한 탐색""" + # 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if i == 0 and j == 0: + return grid[0][0] + # 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if i < 0 or j < 0: + return inf + # 기록이 이미 있으면 바로 반환한다 + if mem[i][j] != -1: + return mem[i][j] + # 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + up = min_path_sum_dfs_mem(grid, mem, i - 1, j) + left = min_path_sum_dfs_mem(grid, mem, i, j - 1) + # 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = min(left, up) + grid[i][j] + return mem[i][j] + ``` + +=== "C++" + + ```cpp title="min_path_sum.cpp" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + int minPathSumDFSMem(vector> &grid, vector> &mem, int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return INT_MAX; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][j] != -1) { + return mem[i][j]; + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + int up = minPathSumDFSMem(grid, mem, i - 1, j); + int left = minPathSumDFSMem(grid, mem, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = min(left, up) != INT_MAX ? min(left, up) + grid[i][j] : INT_MAX; + return mem[i][j]; + } + ``` + +=== "Java" + + ```java title="min_path_sum.java" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + int minPathSumDFSMem(int[][] grid, int[][] mem, int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return Integer.MAX_VALUE; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][j] != -1) { + return mem[i][j]; + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + int up = minPathSumDFSMem(grid, mem, i - 1, j); + int left = minPathSumDFSMem(grid, mem, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = Math.min(left, up) + grid[i][j]; + return mem[i][j]; + } + ``` + +=== "C#" + + ```csharp title="min_path_sum.cs" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + int MinPathSumDFSMem(int[][] grid, int[][] mem, int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return int.MaxValue; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][j] != -1) { + return mem[i][j]; + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + int up = MinPathSumDFSMem(grid, mem, i - 1, j); + int left = MinPathSumDFSMem(grid, mem, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = Math.Min(left, up) + grid[i][j]; + return mem[i][j]; + } + ``` + +=== "Go" + + ```go title="min_path_sum.go" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + func minPathSumDFSMem(grid, mem [][]int, i, j int) int { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if i == 0 && j == 0 { + return grid[0][0] + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if i < 0 || j < 0 { + return math.MaxInt + } + // 기록이 이미 있으면 바로 반환한다 + if mem[i][j] != -1 { + return mem[i][j] + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + up := minPathSumDFSMem(grid, mem, i-1, j) + left := minPathSumDFSMem(grid, mem, i, j-1) + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = int(math.Min(float64(left), float64(up))) + grid[i][j] + return mem[i][j] + } + ``` + +=== "Swift" + + ```swift title="min_path_sum.swift" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + func minPathSumDFSMem(grid: [[Int]], mem: inout [[Int]], i: Int, j: Int) -> Int { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if i == 0, j == 0 { + return grid[0][0] + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if i < 0 || j < 0 { + return .max + } + // 기록이 이미 있으면 바로 반환한다 + if mem[i][j] != -1 { + return mem[i][j] + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + let up = minPathSumDFSMem(grid: grid, mem: &mem, i: i - 1, j: j) + let left = minPathSumDFSMem(grid: grid, mem: &mem, i: i, j: j - 1) + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = min(left, up) + grid[i][j] + return mem[i][j] + } + ``` + +=== "JS" + + ```javascript title="min_path_sum.js" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + function minPathSumDFSMem(grid, mem, i, j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i === 0 && j === 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return Infinity; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][j] !== -1) { + return mem[i][j]; + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + const up = minPathSumDFSMem(grid, mem, i - 1, j); + const left = minPathSumDFSMem(grid, mem, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = Math.min(left, up) + grid[i][j]; + return mem[i][j]; + } + ``` + +=== "TS" + + ```typescript title="min_path_sum.ts" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + function minPathSumDFSMem( + grid: Array>, + mem: Array>, + i: number, + j: number + ): number { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i === 0 && j === 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return Infinity; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][j] != -1) { + return mem[i][j]; + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + const up = minPathSumDFSMem(grid, mem, i - 1, j); + const left = minPathSumDFSMem(grid, mem, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = Math.min(left, up) + grid[i][j]; + return mem[i][j]; + } + ``` + +=== "Dart" + + ```dart title="min_path_sum.dart" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + int minPathSumDFSMem(List> grid, List> mem, int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + // Dart의 int 타입은 범위가 고정된 정수이며, 무한대를 나타내는 값이 없다 + return BigInt.from(2).pow(31).toInt(); + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][j] != -1) { + return mem[i][j]; + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + int up = minPathSumDFSMem(grid, mem, i - 1, j); + int left = minPathSumDFSMem(grid, mem, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = min(left, up) + grid[i][j]; + return mem[i][j]; + } + ``` + +=== "Rust" + + ```rust title="min_path_sum.rs" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + fn min_path_sum_dfs_mem(grid: &Vec>, mem: &mut Vec>, i: i32, j: i32) -> i32 { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if i == 0 && j == 0 { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if i < 0 || j < 0 { + return i32::MAX; + } + // 기록이 이미 있으면 바로 반환한다 + if mem[i as usize][j as usize] != -1 { + return mem[i as usize][j as usize]; + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + let up = min_path_sum_dfs_mem(grid, mem, i - 1, j); + let left = min_path_sum_dfs_mem(grid, mem, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i as usize][j as usize] = std::cmp::min(left, up) + grid[i as usize][j as usize]; + mem[i as usize][j as usize] + } + ``` + +=== "C" + + ```c title="min_path_sum.c" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + int minPathSumDFSMem(int grid[MAX_SIZE][MAX_SIZE], int mem[MAX_SIZE][MAX_SIZE], int i, int j) { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return INT_MAX; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][j] != -1) { + return mem[i][j]; + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + int up = minPathSumDFSMem(grid, mem, i - 1, j); + int left = minPathSumDFSMem(grid, mem, i, j - 1); + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = myMin(left, up) != INT_MAX ? myMin(left, up) + grid[i][j] : INT_MAX; + return mem[i][j]; + } + ``` + +=== "Kotlin" + + ```kotlin title="min_path_sum.kt" + /* 최소 경로 합: 메모이제이션을 이용한 탐색 */ + fun minPathSumDFSMem( + grid: Array, + mem: Array, + i: Int, + j: Int + ): Int { + // 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + if (i == 0 && j == 0) { + return grid[0][0] + } + // 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + if (i < 0 || j < 0) { + return Int.MAX_VALUE + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][j] != -1) { + return mem[i][j] + } + // 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + val up = minPathSumDFSMem(grid, mem, i - 1, j) + val left = minPathSumDFSMem(grid, mem, i, j - 1) + // 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = min(left, up) + grid[i][j] + return mem[i][j] + } + ``` + +=== "Ruby" + + ```ruby title="min_path_sum.rb" + ### 최소 경로 합: 메모이제이션을 이용한 탐색 ### + def min_path_sum_dfs_mem(grid, mem, i, j) + # 왼쪽 위 모서리의 칸이면 탐색을 종료한다 + return grid[0][0] if i == 0 && j == 0 + # 행 또는 열 인덱스가 범위를 벗어나면 비용 +∞를 반환한다 + return Float::INFINITY if i < 0 || j < 0 + # 기록이 이미 있으면 바로 반환한다 + return mem[i][j] if mem[i][j] != -1 + # 왼쪽 칸과 위쪽 칸까지의 최소 경로 비용 + up = min_path_sum_dfs_mem(grid, mem, i - 1, j) + left = min_path_sum_dfs_mem(grid, mem, i, j - 1) + # 왼쪽 위 모서리에서 (i, j)까지의 최소 경로 비용을 기록하고 반환한다 + mem[i][j] = [left, up].min + grid[i][j] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-15)과 같이 메모이제이션을 도입하면 모든 부분 문제의 해를 한 번만 계산한다. 따라서 시간 복잡도는 전체 상태 수, 즉 격자 크기인 $O(nm)$에 의해 결정된다. + +![메모이제이션을 이용한 탐색의 재귀 트리](dp_solution_pipeline.assets/min_path_sum_dfs_mem.png){ class="animation-figure" } + +

그림 14-15   메모이제이션을 이용한 탐색의 재귀 트리

+ +### 3.   방법 3: 동적 계획법 + +반복을 이용한 동적 계획법 구현은 다음과 같다. + +=== "Python" + + ```python title="min_path_sum.py" + def min_path_sum_dp(grid: list[list[int]]) -> int: + """최소 경로 합: 동적 계획법""" + n, m = len(grid), len(grid[0]) + # dp 테이블을 초기화한다 + dp = [[0] * m for _ in range(n)] + dp[0][0] = grid[0][0] + # 상태 전이: 첫 번째 행 + for j in range(1, m): + dp[0][j] = dp[0][j - 1] + grid[0][j] + # 상태 전이: 첫 번째 열 + for i in range(1, n): + dp[i][0] = dp[i - 1][0] + grid[i][0] + # 상태 전이: 나머지 행과 열 + for i in range(1, n): + for j in range(1, m): + dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j] + return dp[n - 1][m - 1] + ``` + +=== "C++" + + ```cpp title="min_path_sum.cpp" + /* 최소 경로 합: 동적 계획법 */ + int minPathSumDP(vector> &grid) { + int n = grid.size(), m = grid[0].size(); + // dp 테이블을 초기화한다 + vector> dp(n, vector(m)); + dp[0][0] = grid[0][0]; + // 상태 전이: 첫 번째 행 + for (int j = 1; j < m; j++) { + dp[0][j] = dp[0][j - 1] + grid[0][j]; + } + // 상태 전이: 첫 번째 열 + for (int i = 1; i < n; i++) { + dp[i][0] = dp[i - 1][0] + grid[i][0]; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i < n; i++) { + for (int j = 1; j < m; j++) { + dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; + } + } + return dp[n - 1][m - 1]; + } + ``` + +=== "Java" + + ```java title="min_path_sum.java" + /* 최소 경로 합: 동적 계획법 */ + int minPathSumDP(int[][] grid) { + int n = grid.length, m = grid[0].length; + // dp 테이블을 초기화한다 + int[][] dp = new int[n][m]; + dp[0][0] = grid[0][0]; + // 상태 전이: 첫 번째 행 + for (int j = 1; j < m; j++) { + dp[0][j] = dp[0][j - 1] + grid[0][j]; + } + // 상태 전이: 첫 번째 열 + for (int i = 1; i < n; i++) { + dp[i][0] = dp[i - 1][0] + grid[i][0]; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i < n; i++) { + for (int j = 1; j < m; j++) { + dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; + } + } + return dp[n - 1][m - 1]; + } + ``` + +=== "C#" + + ```csharp title="min_path_sum.cs" + /* 최소 경로 합: 동적 계획법 */ + int MinPathSumDP(int[][] grid) { + int n = grid.Length, m = grid[0].Length; + // dp 테이블을 초기화한다 + int[,] dp = new int[n, m]; + dp[0, 0] = grid[0][0]; + // 상태 전이: 첫 번째 행 + for (int j = 1; j < m; j++) { + dp[0, j] = dp[0, j - 1] + grid[0][j]; + } + // 상태 전이: 첫 번째 열 + for (int i = 1; i < n; i++) { + dp[i, 0] = dp[i - 1, 0] + grid[i][0]; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i < n; i++) { + for (int j = 1; j < m; j++) { + dp[i, j] = Math.Min(dp[i, j - 1], dp[i - 1, j]) + grid[i][j]; + } + } + return dp[n - 1, m - 1]; + } + ``` + +=== "Go" + + ```go title="min_path_sum.go" + /* 최소 경로 합: 동적 계획법 */ + func minPathSumDP(grid [][]int) int { + n, m := len(grid), len(grid[0]) + // dp 테이블을 초기화한다 + dp := make([][]int, n) + for i := 0; i < n; i++ { + dp[i] = make([]int, m) + } + dp[0][0] = grid[0][0] + // 상태 전이: 첫 번째 행 + for j := 1; j < m; j++ { + dp[0][j] = dp[0][j-1] + grid[0][j] + } + // 상태 전이: 첫 번째 열 + for i := 1; i < n; i++ { + dp[i][0] = dp[i-1][0] + grid[i][0] + } + // 상태 전이: 나머지 행과 열 + for i := 1; i < n; i++ { + for j := 1; j < m; j++ { + dp[i][j] = int(math.Min(float64(dp[i][j-1]), float64(dp[i-1][j]))) + grid[i][j] + } + } + return dp[n-1][m-1] + } + ``` + +=== "Swift" + + ```swift title="min_path_sum.swift" + /* 최소 경로 합: 동적 계획법 */ + func minPathSumDP(grid: [[Int]]) -> Int { + let n = grid.count + let m = grid[0].count + // dp 테이블을 초기화한다 + var dp = Array(repeating: Array(repeating: 0, count: m), count: n) + dp[0][0] = grid[0][0] + // 상태 전이: 첫 번째 행 + for j in 1 ..< m { + dp[0][j] = dp[0][j - 1] + grid[0][j] + } + // 상태 전이: 첫 번째 열 + for i in 1 ..< n { + dp[i][0] = dp[i - 1][0] + grid[i][0] + } + // 상태 전이: 나머지 행과 열 + for i in 1 ..< n { + for j in 1 ..< m { + dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j] + } + } + return dp[n - 1][m - 1] + } + ``` + +=== "JS" + + ```javascript title="min_path_sum.js" + /* 최소 경로 합: 동적 계획법 */ + function minPathSumDP(grid) { + const n = grid.length, + m = grid[0].length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: n }, () => + Array.from({ length: m }, () => 0) + ); + dp[0][0] = grid[0][0]; + // 상태 전이: 첫 번째 행 + for (let j = 1; j < m; j++) { + dp[0][j] = dp[0][j - 1] + grid[0][j]; + } + // 상태 전이: 첫 번째 열 + for (let i = 1; i < n; i++) { + dp[i][0] = dp[i - 1][0] + grid[i][0]; + } + // 상태 전이: 나머지 행과 열 + for (let i = 1; i < n; i++) { + for (let j = 1; j < m; j++) { + dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; + } + } + return dp[n - 1][m - 1]; + } + ``` + +=== "TS" + + ```typescript title="min_path_sum.ts" + /* 최소 경로 합: 동적 계획법 */ + function minPathSumDP(grid: Array>): number { + const n = grid.length, + m = grid[0].length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: n }, () => + Array.from({ length: m }, () => 0) + ); + dp[0][0] = grid[0][0]; + // 상태 전이: 첫 번째 행 + for (let j = 1; j < m; j++) { + dp[0][j] = dp[0][j - 1] + grid[0][j]; + } + // 상태 전이: 첫 번째 열 + for (let i = 1; i < n; i++) { + dp[i][0] = dp[i - 1][0] + grid[i][0]; + } + // 상태 전이: 나머지 행과 열 + for (let i = 1; i < n; i++) { + for (let j: number = 1; j < m; j++) { + dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; + } + } + return dp[n - 1][m - 1]; + } + ``` + +=== "Dart" + + ```dart title="min_path_sum.dart" + /* 최소 경로 합: 동적 계획법 */ + int minPathSumDP(List> grid) { + int n = grid.length, m = grid[0].length; + // dp 테이블을 초기화한다 + List> dp = List.generate(n, (i) => List.filled(m, 0)); + dp[0][0] = grid[0][0]; + // 상태 전이: 첫 번째 행 + for (int j = 1; j < m; j++) { + dp[0][j] = dp[0][j - 1] + grid[0][j]; + } + // 상태 전이: 첫 번째 열 + for (int i = 1; i < n; i++) { + dp[i][0] = dp[i - 1][0] + grid[i][0]; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i < n; i++) { + for (int j = 1; j < m; j++) { + dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; + } + } + return dp[n - 1][m - 1]; + } + ``` + +=== "Rust" + + ```rust title="min_path_sum.rs" + /* 최소 경로 합: 동적 계획법 */ + fn min_path_sum_dp(grid: &Vec>) -> i32 { + let (n, m) = (grid.len(), grid[0].len()); + // dp 테이블을 초기화한다 + let mut dp = vec![vec![0; m]; n]; + dp[0][0] = grid[0][0]; + // 상태 전이: 첫 번째 행 + for j in 1..m { + dp[0][j] = dp[0][j - 1] + grid[0][j]; + } + // 상태 전이: 첫 번째 열 + for i in 1..n { + dp[i][0] = dp[i - 1][0] + grid[i][0]; + } + // 상태 전이: 나머지 행과 열 + for i in 1..n { + for j in 1..m { + dp[i][j] = std::cmp::min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; + } + } + dp[n - 1][m - 1] + } + ``` + +=== "C" + + ```c title="min_path_sum.c" + /* 최소 경로 합: 동적 계획법 */ + int minPathSumDP(int grid[MAX_SIZE][MAX_SIZE], int n, int m) { + // dp 테이블을 초기화한다 + int **dp = malloc(n * sizeof(int *)); + for (int i = 0; i < n; i++) { + dp[i] = calloc(m, sizeof(int)); + } + dp[0][0] = grid[0][0]; + // 상태 전이: 첫 번째 행 + for (int j = 1; j < m; j++) { + dp[0][j] = dp[0][j - 1] + grid[0][j]; + } + // 상태 전이: 첫 번째 열 + for (int i = 1; i < n; i++) { + dp[i][0] = dp[i - 1][0] + grid[i][0]; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i < n; i++) { + for (int j = 1; j < m; j++) { + dp[i][j] = myMin(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]; + } + } + int res = dp[n - 1][m - 1]; + // 메모리 해제 + for (int i = 0; i < n; i++) { + free(dp[i]); + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="min_path_sum.kt" + /* 최소 경로 합: 동적 계획법 */ + fun minPathSumDP(grid: Array): Int { + val n = grid.size + val m = grid[0].size + // dp 테이블을 초기화한다 + val dp = Array(n) { IntArray(m) } + dp[0][0] = grid[0][0] + // 상태 전이: 첫 번째 행 + for (j in 1.. + + +아래 그림(14-16)의 최소 경로 합 상태 전이 과정에서는 전체 격자를 순회하므로 **시간 복잡도는 $O(nm)$이다**. + +배열 `dp`의 크기는 $n \times m$이므로 **공간 복잡도는 $O(nm)$이다**. + +=== "<1>" + ![최소 경로 합의 동적 계획법 과정](dp_solution_pipeline.assets/min_path_sum_dp_step1.png){ class="animation-figure" } + +=== "<2>" + ![최소 경로 합 동적 계획법 2단계](dp_solution_pipeline.assets/min_path_sum_dp_step2.png){ class="animation-figure" } + +=== "<3>" + ![최소 경로 합 동적 계획법 3단계](dp_solution_pipeline.assets/min_path_sum_dp_step3.png){ class="animation-figure" } + +=== "<4>" + ![최소 경로 합 동적 계획법 4단계](dp_solution_pipeline.assets/min_path_sum_dp_step4.png){ class="animation-figure" } + +=== "<5>" + ![최소 경로 합 동적 계획법 5단계](dp_solution_pipeline.assets/min_path_sum_dp_step5.png){ class="animation-figure" } + +=== "<6>" + ![최소 경로 합 동적 계획법 6단계](dp_solution_pipeline.assets/min_path_sum_dp_step6.png){ class="animation-figure" } + +=== "<7>" + ![최소 경로 합 동적 계획법 7단계](dp_solution_pipeline.assets/min_path_sum_dp_step7.png){ class="animation-figure" } + +=== "<8>" + ![최소 경로 합 동적 계획법 8단계](dp_solution_pipeline.assets/min_path_sum_dp_step8.png){ class="animation-figure" } + +=== "<9>" + ![최소 경로 합 동적 계획법 9단계](dp_solution_pipeline.assets/min_path_sum_dp_step9.png){ class="animation-figure" } + +=== "<10>" + ![최소 경로 합 동적 계획법 10단계](dp_solution_pipeline.assets/min_path_sum_dp_step10.png){ class="animation-figure" } + +=== "<11>" + ![최소 경로 합 동적 계획법 11단계](dp_solution_pipeline.assets/min_path_sum_dp_step11.png){ class="animation-figure" } + +=== "<12>" + ![최소 경로 합 동적 계획법 12단계](dp_solution_pipeline.assets/min_path_sum_dp_step12.png){ class="animation-figure" } + +

그림 14-16   최소 경로 합의 동적 계획법 과정

+ +### 4.   공간 최적화 + +각 칸은 왼쪽 칸과 위쪽 칸에만 의존하므로 한 행짜리 배열만으로 $dp$ 테이블을 구현할 수 있다. + +배열 `dp`가 한 행의 상태만 나타내므로 첫 열의 상태를 미리 초기화할 수는 없다. 대신 각 행을 순회할 때 갱신해야 한다. + +=== "Python" + + ```python title="min_path_sum.py" + def min_path_sum_dp_comp(grid: list[list[int]]) -> int: + """최소 경로 합: 공간을 최적화한 동적 계획법""" + n, m = len(grid), len(grid[0]) + # dp 테이블을 초기화한다 + dp = [0] * m + # 상태 전이: 첫 번째 행 + dp[0] = grid[0][0] + for j in range(1, m): + dp[j] = dp[j - 1] + grid[0][j] + # 상태 전이: 나머지 행 + for i in range(1, n): + # 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0] + # 상태 전이: 나머지 열 + for j in range(1, m): + dp[j] = min(dp[j - 1], dp[j]) + grid[i][j] + return dp[m - 1] + ``` + +=== "C++" + + ```cpp title="min_path_sum.cpp" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + int minPathSumDPComp(vector> &grid) { + int n = grid.size(), m = grid[0].size(); + // dp 테이블을 초기화한다 + vector dp(m); + // 상태 전이: 첫 번째 행 + dp[0] = grid[0][0]; + for (int j = 1; j < m; j++) { + dp[j] = dp[j - 1] + grid[0][j]; + } + // 상태 전이: 나머지 행 + for (int i = 1; i < n; i++) { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0]; + // 상태 전이: 나머지 열 + for (int j = 1; j < m; j++) { + dp[j] = min(dp[j - 1], dp[j]) + grid[i][j]; + } + } + return dp[m - 1]; + } + ``` + +=== "Java" + + ```java title="min_path_sum.java" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + int minPathSumDPComp(int[][] grid) { + int n = grid.length, m = grid[0].length; + // dp 테이블을 초기화한다 + int[] dp = new int[m]; + // 상태 전이: 첫 번째 행 + dp[0] = grid[0][0]; + for (int j = 1; j < m; j++) { + dp[j] = dp[j - 1] + grid[0][j]; + } + // 상태 전이: 나머지 행 + for (int i = 1; i < n; i++) { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0]; + // 상태 전이: 나머지 열 + for (int j = 1; j < m; j++) { + dp[j] = Math.min(dp[j - 1], dp[j]) + grid[i][j]; + } + } + return dp[m - 1]; + } + ``` + +=== "C#" + + ```csharp title="min_path_sum.cs" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + int MinPathSumDPComp(int[][] grid) { + int n = grid.Length, m = grid[0].Length; + // dp 테이블을 초기화한다 + int[] dp = new int[m]; + dp[0] = grid[0][0]; + // 상태 전이: 첫 번째 행 + for (int j = 1; j < m; j++) { + dp[j] = dp[j - 1] + grid[0][j]; + } + // 상태 전이: 나머지 행 + for (int i = 1; i < n; i++) { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0]; + // 상태 전이: 나머지 열 + for (int j = 1; j < m; j++) { + dp[j] = Math.Min(dp[j - 1], dp[j]) + grid[i][j]; + } + } + return dp[m - 1]; + } + ``` + +=== "Go" + + ```go title="min_path_sum.go" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + func minPathSumDPComp(grid [][]int) int { + n, m := len(grid), len(grid[0]) + // dp 테이블을 초기화한다 + dp := make([]int, m) + // 상태 전이: 첫 번째 행 + dp[0] = grid[0][0] + for j := 1; j < m; j++ { + dp[j] = dp[j-1] + grid[0][j] + } + // 상태 전이: 나머지 행과 열 + for i := 1; i < n; i++ { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0] + // 상태 전이: 나머지 열 + for j := 1; j < m; j++ { + dp[j] = int(math.Min(float64(dp[j-1]), float64(dp[j]))) + grid[i][j] + } + } + return dp[m-1] + } + ``` + +=== "Swift" + + ```swift title="min_path_sum.swift" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + func minPathSumDPComp(grid: [[Int]]) -> Int { + let n = grid.count + let m = grid[0].count + // dp 테이블을 초기화한다 + var dp = Array(repeating: 0, count: m) + // 상태 전이: 첫 번째 행 + dp[0] = grid[0][0] + for j in 1 ..< m { + dp[j] = dp[j - 1] + grid[0][j] + } + // 상태 전이: 나머지 행 + for i in 1 ..< n { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0] + // 상태 전이: 나머지 열 + for j in 1 ..< m { + dp[j] = min(dp[j - 1], dp[j]) + grid[i][j] + } + } + return dp[m - 1] + } + ``` + +=== "JS" + + ```javascript title="min_path_sum.js" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + function minPathSumDPComp(grid) { + const n = grid.length, + m = grid[0].length; + // dp 테이블을 초기화한다 + const dp = new Array(m); + // 상태 전이: 첫 번째 행 + dp[0] = grid[0][0]; + for (let j = 1; j < m; j++) { + dp[j] = dp[j - 1] + grid[0][j]; + } + // 상태 전이: 나머지 행 + for (let i = 1; i < n; i++) { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0]; + // 상태 전이: 나머지 열 + for (let j = 1; j < m; j++) { + dp[j] = Math.min(dp[j - 1], dp[j]) + grid[i][j]; + } + } + return dp[m - 1]; + } + ``` + +=== "TS" + + ```typescript title="min_path_sum.ts" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + function minPathSumDPComp(grid: Array>): number { + const n = grid.length, + m = grid[0].length; + // dp 테이블을 초기화한다 + const dp = new Array(m); + // 상태 전이: 첫 번째 행 + dp[0] = grid[0][0]; + for (let j = 1; j < m; j++) { + dp[j] = dp[j - 1] + grid[0][j]; + } + // 상태 전이: 나머지 행 + for (let i = 1; i < n; i++) { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0]; + // 상태 전이: 나머지 열 + for (let j = 1; j < m; j++) { + dp[j] = Math.min(dp[j - 1], dp[j]) + grid[i][j]; + } + } + return dp[m - 1]; + } + ``` + +=== "Dart" + + ```dart title="min_path_sum.dart" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + int minPathSumDPComp(List> grid) { + int n = grid.length, m = grid[0].length; + // dp 테이블을 초기화한다 + List dp = List.filled(m, 0); + dp[0] = grid[0][0]; + for (int j = 1; j < m; j++) { + dp[j] = dp[j - 1] + grid[0][j]; + } + // 상태 전이: 나머지 행 + for (int i = 1; i < n; i++) { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0]; + // 상태 전이: 나머지 열 + for (int j = 1; j < m; j++) { + dp[j] = min(dp[j - 1], dp[j]) + grid[i][j]; + } + } + return dp[m - 1]; + } + ``` + +=== "Rust" + + ```rust title="min_path_sum.rs" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + fn min_path_sum_dp_comp(grid: &Vec>) -> i32 { + let (n, m) = (grid.len(), grid[0].len()); + // dp 테이블을 초기화한다 + let mut dp = vec![0; m]; + // 상태 전이: 첫 번째 행 + dp[0] = grid[0][0]; + for j in 1..m { + dp[j] = dp[j - 1] + grid[0][j]; + } + // 상태 전이: 나머지 행 + for i in 1..n { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0]; + // 상태 전이: 나머지 열 + for j in 1..m { + dp[j] = std::cmp::min(dp[j - 1], dp[j]) + grid[i][j]; + } + } + dp[m - 1] + } + ``` + +=== "C" + + ```c title="min_path_sum.c" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + int minPathSumDPComp(int grid[MAX_SIZE][MAX_SIZE], int n, int m) { + // dp 테이블을 초기화한다 + int *dp = calloc(m, sizeof(int)); + // 상태 전이: 첫 번째 행 + dp[0] = grid[0][0]; + for (int j = 1; j < m; j++) { + dp[j] = dp[j - 1] + grid[0][j]; + } + // 상태 전이: 나머지 행 + for (int i = 1; i < n; i++) { + // 상태 전이: 첫 번째 열 + dp[0] = dp[0] + grid[i][0]; + // 상태 전이: 나머지 열 + for (int j = 1; j < m; j++) { + dp[j] = myMin(dp[j - 1], dp[j]) + grid[i][j]; + } + } + int res = dp[m - 1]; + // 메모리 해제 + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="min_path_sum.kt" + /* 최소 경로 합: 공간을 최적화한 동적 계획법 */ + fun minPathSumDPComp(grid: Array): Int { + val n = grid.size + val m = grid[0].size + // dp 테이블을 초기화한다 + val dp = IntArray(m) + // 상태 전이: 첫 번째 행 + dp[0] = grid[0][0] + for (j in 1.. + diff --git a/ko/docs/chapter_dynamic_programming/edit_distance_problem.md b/ko/docs/chapter_dynamic_programming/edit_distance_problem.md new file mode 100644 index 000000000..3eb2e8ddf --- /dev/null +++ b/ko/docs/chapter_dynamic_programming/edit_distance_problem.md @@ -0,0 +1,972 @@ +--- +comments: true +--- + +# 14.6   편집 거리 문제 + +편집 거리 또는 Levenshtein 거리는 두 문자열을 서로 바꾸는 데 필요한 최소 수정 횟수다. 정보 검색과 자연어 처리에서 두 시퀀스의 유사도를 측정할 때 자주 사용한다. + +!!! question "문제" + + 두 문자열 $s$와 $t$를 입력받아 $s$를 $t$로 바꾸는 데 필요한 최소 편집 횟수를 반환하라. + + 문자열에 문자를 하나 삽입하거나, 하나 삭제하거나, 한 문자를 임의의 다른 문자로 바꾸는 세 연산을 할 수 있다. + +아래 그림(14-27)과 같이 `kitten`을 `sitting`으로 바꾸려면 교체 2회와 추가 1회로 총 3회 편집해야 한다. `hello`를 `algo`로 바꾸려면 교체 2회와 삭제 1회로 총 3회가 필요하다. + +![편집 거리 예시 데이터](edit_distance_problem.assets/edit_distance_example.png){ class="animation-figure" } + +

그림 14-27   편집 거리 예시 데이터

+ +**편집 거리 문제는 의사 결정 트리 모형으로 자연스럽게 설명할 수 있다**. 문자열은 트리 노드에, 한 번의 결정(편집 연산)은 간선 하나에 대응한다. + +아래 그림(14-28)과 같이 연산을 제한하지 않으면 각 노드에서 다양한 연산에 대응하는 많은 간선이 뻗어 나온다. 즉, `hello`에서 `algo`로 가는 경로는 매우 많다. + +의사 결정 트리의 관점에서 이 문제의 목표는 노드 `hello`와 노드 `algo` 사이의 최단 경로를 구하는 것이다. + +![의사 결정 트리로 표현한 편집 거리 문제](edit_distance_problem.assets/edit_distance_decision_tree.png){ class="animation-figure" } + +

그림 14-28   의사 결정 트리로 표현한 편집 거리 문제

+ +### 1.   동적 계획법 접근 + +**1단계: 매 차례의 결정을 생각하고 상태를 정의하여 $dp$ 테이블을 얻는다** + +매 차례의 결정은 문자열 $s$에 편집 연산을 한 번 적용하는 것이다. + +부분 문제를 만들려면 편집하는 동안 문제의 크기가 점차 줄어들어야 한다. 문자열 $s$와 $t$의 길이를 각각 $n$과 $m$이라고 하고, 두 문자열의 마지막 문자 $s[n-1]$과 $t[m-1]$을 먼저 살펴보자. + +- $s[n-1]$과 $t[m-1]$이 같으면 두 문자를 건너뛰고 $s[n-2]$와 $t[m-2]$를 바로 살펴볼 수 있다. +- $s[n-1]$과 $t[m-1]$이 다르면 $s$에 편집(삽입, 삭제, 교체)을 한 번 적용하여 끝 문자가 같아지게 하고, 이를 건너뛰어 더 작은 문제를 살펴본다. + +즉, 문자열 $s$에서 결정(편집 연산)을 할 때마다 $s$와 $t$에서 아직 맞추어야 하는 문자들이 달라진다. 따라서 현재 살펴보는 $s$의 $i$ 번째 문자와 $t$의 $j$ 번째 문자를 상태로 정의하고 $[i, j]$라고 한다. + +상태 $[i, j]$의 부분 문제는 **$s$의 앞 $i$ 개 문자를 $t$의 앞 $j$ 개 문자로 바꾸는 최소 편집 횟수**다. + +이로써 $(n+1) \times (m+1)$ 크기의 2차원 $dp$ 테이블을 얻는다. + +**2단계: 최적 부분 구조를 찾아 상태 전이 식을 도출한다** + +부분 문제 $dp[i, j]$에 해당하는 두 문자열의 끝 문자는 $s[i-1]$과 $t[j-1]$이다. 편집 연산에 따라 아래 그림(14-29)의 세 경우로 나뉜다. + +1. $s[i-1]$ 뒤에 $t[j-1]$을 추가하면 부분 문제 $dp[i, j-1]$이 남는다. +2. $s[i-1]$을 삭제하면 부분 문제 $dp[i-1, j]$가 남는다. +3. $s[i-1]$을 $t[j-1]$로 바꾸면 부분 문제 $dp[i-1, j-1]$이 남는다. + +![편집 거리의 상태 전이](edit_distance_problem.assets/edit_distance_state_transfer.png){ class="animation-figure" } + +

그림 14-29   편집 거리의 상태 전이

+ +위 분석으로 최적 부분 구조를 얻는다. $dp[i, j]$의 최소 편집 횟수는 $dp[i, j-1]$, $dp[i-1, j]$, $dp[i-1, j-1]$ 중 가장 작은 편집 횟수에 이번 편집 횟수 $1$을 더한 값이다. 상태 전이 식은 다음과 같다. + +$$ +dp[i, j] = \min(dp[i, j-1], dp[i-1, j], dp[i-1, j-1]) + 1 +$$ + +**$s[i-1]$과 $t[j-1]$이 같으면 현재 문자를 편집할 필요가 없으며**, 이때 상태 전이 식은 다음과 같다. + +$$ +dp[i, j] = dp[i-1, j-1] +$$ + +**3단계: 경계 조건과 상태 전이 순서를 정한다** + +두 문자열이 모두 비어 있으면 편집 횟수는 $0$이므로 $dp[0, 0] = 0$이다. $s$만 비어 있고 $t$는 비어 있지 않으면 최소 편집 횟수는 $t$의 길이이므로 첫 행은 $dp[0, j] = j$다. 반대로 $s$는 비어 있지 않고 $t$가 비어 있으면 최소 편집 횟수는 $s$의 길이이므로 첫 열은 $dp[i, 0] = i$다. + +상태 전이 식에서 $dp[i, j]$는 왼쪽, 위쪽, 왼쪽 위의 해에 의존하므로 이중 반복문으로 전체 $dp$ 테이블을 정방향으로 순회하면 된다. + +### 2.   코드 구현 + +=== "Python" + + ```python title="edit_distance.py" + def edit_distance_dp(s: str, t: str) -> int: + """편집 거리: 동적 계획법""" + n, m = len(s), len(t) + dp = [[0] * (m + 1) for _ in range(n + 1)] + # 상태 전이: 첫 번째 행과 첫 번째 열 + for i in range(1, n + 1): + dp[i][0] = i + for j in range(1, m + 1): + dp[0][j] = j + # 상태 전이: 나머지 행과 열 + for i in range(1, n + 1): + for j in range(1, m + 1): + if s[i - 1] == t[j - 1]: + # 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1] + else: + # 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]) + 1 + return dp[n][m] + ``` + +=== "C++" + + ```cpp title="edit_distance.cpp" + /* 편집 거리: 동적 계획법 */ + int editDistanceDP(string s, string t) { + int n = s.length(), m = t.length(); + vector> dp(n + 1, vector(m + 1, 0)); + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int i = 1; i <= n; i++) { + dp[i][0] = i; + } + for (int j = 1; j <= m; j++) { + dp[0][j] = j; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int j = 1; j <= m; j++) { + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1]; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = min(min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1; + } + } + } + return dp[n][m]; + } + ``` + +=== "Java" + + ```java title="edit_distance.java" + /* 편집 거리: 동적 계획법 */ + int editDistanceDP(String s, String t) { + int n = s.length(), m = t.length(); + int[][] dp = new int[n + 1][m + 1]; + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int i = 1; i <= n; i++) { + dp[i][0] = i; + } + for (int j = 1; j <= m; j++) { + dp[0][j] = j; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int j = 1; j <= m; j++) { + if (s.charAt(i - 1) == t.charAt(j - 1)) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1]; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = Math.min(Math.min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1; + } + } + } + return dp[n][m]; + } + ``` + +=== "C#" + + ```csharp title="edit_distance.cs" + /* 편집 거리: 동적 계획법 */ + int EditDistanceDP(string s, string t) { + int n = s.Length, m = t.Length; + int[,] dp = new int[n + 1, m + 1]; + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int i = 1; i <= n; i++) { + dp[i, 0] = i; + } + for (int j = 1; j <= m; j++) { + dp[0, j] = j; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int j = 1; j <= m; j++) { + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i, j] = dp[i - 1, j - 1]; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i, j] = Math.Min(Math.Min(dp[i, j - 1], dp[i - 1, j]), dp[i - 1, j - 1]) + 1; + } + } + } + return dp[n, m]; + } + ``` + +=== "Go" + + ```go title="edit_distance.go" + /* 편집 거리: 동적 계획법 */ + func editDistanceDP(s string, t string) int { + n := len(s) + m := len(t) + dp := make([][]int, n+1) + for i := 0; i <= n; i++ { + dp[i] = make([]int, m+1) + } + // 상태 전이: 첫 번째 행과 첫 번째 열 + for i := 1; i <= n; i++ { + dp[i][0] = i + } + for j := 1; j <= m; j++ { + dp[0][j] = j + } + // 상태 전이: 나머지 행과 열 + for i := 1; i <= n; i++ { + for j := 1; j <= m; j++ { + if s[i-1] == t[j-1] { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i-1][j-1] + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = MinInt(MinInt(dp[i][j-1], dp[i-1][j]), dp[i-1][j-1]) + 1 + } + } + } + return dp[n][m] + } + ``` + +=== "Swift" + + ```swift title="edit_distance.swift" + /* 편집 거리: 동적 계획법 */ + func editDistanceDP(s: String, t: String) -> Int { + let n = s.utf8CString.count + let m = t.utf8CString.count + var dp = Array(repeating: Array(repeating: 0, count: m + 1), count: n + 1) + // 상태 전이: 첫 번째 행과 첫 번째 열 + for i in 1 ... n { + dp[i][0] = i + } + for j in 1 ... m { + dp[0][j] = j + } + // 상태 전이: 나머지 행과 열 + for i in 1 ... n { + for j in 1 ... m { + if s.utf8CString[i - 1] == t.utf8CString[j - 1] { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1] + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = min(min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1 + } + } + } + return dp[n][m] + } + ``` + +=== "JS" + + ```javascript title="edit_distance.js" + /* 편집 거리: 동적 계획법 */ + function editDistanceDP(s, t) { + const n = s.length, + m = t.length; + const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (let i = 1; i <= n; i++) { + dp[i][0] = i; + } + for (let j = 1; j <= m; j++) { + dp[0][j] = j; + } + // 상태 전이: 나머지 행과 열 + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (s.charAt(i - 1) === t.charAt(j - 1)) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1]; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = + Math.min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]) + 1; + } + } + } + return dp[n][m]; + } + ``` + +=== "TS" + + ```typescript title="edit_distance.ts" + /* 편집 거리: 동적 계획법 */ + function editDistanceDP(s: string, t: string): number { + const n = s.length, + m = t.length; + const dp = Array.from({ length: n + 1 }, () => + Array.from({ length: m + 1 }, () => 0) + ); + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (let i = 1; i <= n; i++) { + dp[i][0] = i; + } + for (let j = 1; j <= m; j++) { + dp[0][j] = j; + } + // 상태 전이: 나머지 행과 열 + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (s.charAt(i - 1) === t.charAt(j - 1)) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1]; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = + Math.min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]) + 1; + } + } + } + return dp[n][m]; + } + ``` + +=== "Dart" + + ```dart title="edit_distance.dart" + /* 편집 거리: 동적 계획법 */ + int editDistanceDP(String s, String t) { + int n = s.length, m = t.length; + List> dp = List.generate(n + 1, (_) => List.filled(m + 1, 0)); + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int i = 1; i <= n; i++) { + dp[i][0] = i; + } + for (int j = 1; j <= m; j++) { + dp[0][j] = j; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int j = 1; j <= m; j++) { + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1]; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = min(min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1; + } + } + } + return dp[n][m]; + } + ``` + +=== "Rust" + + ```rust title="edit_distance.rs" + /* 편집 거리: 동적 계획법 */ + fn edit_distance_dp(s: &str, t: &str) -> i32 { + let (n, m) = (s.len(), t.len()); + let mut dp = vec![vec![0; m + 1]; n + 1]; + // 상태 전이: 첫 번째 행과 첫 번째 열 + for i in 1..=n { + dp[i][0] = i as i32; + } + for j in 1..m { + dp[0][j] = j as i32; + } + // 상태 전이: 나머지 행과 열 + for i in 1..=n { + for j in 1..=m { + if s.chars().nth(i - 1) == t.chars().nth(j - 1) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1]; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = + std::cmp::min(std::cmp::min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1; + } + } + } + dp[n][m] + } + ``` + +=== "C" + + ```c title="edit_distance.c" + /* 편집 거리: 동적 계획법 */ + int editDistanceDP(char *s, char *t, int n, int m) { + int **dp = malloc((n + 1) * sizeof(int *)); + for (int i = 0; i <= n; i++) { + dp[i] = calloc(m + 1, sizeof(int)); + } + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int i = 1; i <= n; i++) { + dp[i][0] = i; + } + for (int j = 1; j <= m; j++) { + dp[0][j] = j; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int j = 1; j <= m; j++) { + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1]; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = myMin(myMin(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1; + } + } + } + int res = dp[n][m]; + // 메모리 해제 + for (int i = 0; i <= n; i++) { + free(dp[i]); + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="edit_distance.kt" + /* 편집 거리: 동적 계획법 */ + fun editDistanceDP(s: String, t: String): Int { + val n = s.length + val m = t.length + val dp = Array(n + 1) { IntArray(m + 1) } + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (i in 1..n) { + dp[i][0] = i + } + for (j in 1..m) { + dp[0][j] = j + } + // 상태 전이: 나머지 행과 열 + for (i in 1..n) { + for (j in 1..m) { + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1] + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = min(min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1 + } + } + } + return dp[n][m] + } + ``` + +=== "Ruby" + + ```ruby title="edit_distance.rb" + ### 편집 거리: 동적 계획법 ### + def edit_distance_dp(s, t) + n, m = s.length, t.length + dp = Array.new(n + 1) { Array.new(m + 1, 0) } + # 상태 전이: 첫 번째 행과 첫 번째 열 + (1...(n + 1)).each { |i| dp[i][0] = i } + (1...(m + 1)).each { |j| dp[0][j] = j } + # 상태 전이: 나머지 행과 열 + for i in 1...(n + 1) + for j in 1...(m +1) + if s[i - 1] == t[j - 1] + # 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[i][j] = dp[i - 1][j - 1] + else + # 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[i][j] = [dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]].min + 1 + end + end + end + dp[n][m] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-30)과 같이 편집 거리의 상태 전이는 배낭 문제와 매우 비슷하며, 모두 2차원 격자를 채우는 과정으로 볼 수 있다. + +=== "<1>" + ![편집 거리의 동적 계획법 과정](edit_distance_problem.assets/edit_distance_dp_step1.png){ class="animation-figure" } + +=== "<2>" + ![편집 거리 동적 계획법 2단계](edit_distance_problem.assets/edit_distance_dp_step2.png){ class="animation-figure" } + +=== "<3>" + ![편집 거리 동적 계획법 3단계](edit_distance_problem.assets/edit_distance_dp_step3.png){ class="animation-figure" } + +=== "<4>" + ![편집 거리 동적 계획법 4단계](edit_distance_problem.assets/edit_distance_dp_step4.png){ class="animation-figure" } + +=== "<5>" + ![편집 거리 동적 계획법 5단계](edit_distance_problem.assets/edit_distance_dp_step5.png){ class="animation-figure" } + +=== "<6>" + ![편집 거리 동적 계획법 6단계](edit_distance_problem.assets/edit_distance_dp_step6.png){ class="animation-figure" } + +=== "<7>" + ![편집 거리 동적 계획법 7단계](edit_distance_problem.assets/edit_distance_dp_step7.png){ class="animation-figure" } + +=== "<8>" + ![편집 거리 동적 계획법 8단계](edit_distance_problem.assets/edit_distance_dp_step8.png){ class="animation-figure" } + +=== "<9>" + ![편집 거리 동적 계획법 9단계](edit_distance_problem.assets/edit_distance_dp_step9.png){ class="animation-figure" } + +=== "<10>" + ![편집 거리 동적 계획법 10단계](edit_distance_problem.assets/edit_distance_dp_step10.png){ class="animation-figure" } + +=== "<11>" + ![편집 거리 동적 계획법 11단계](edit_distance_problem.assets/edit_distance_dp_step11.png){ class="animation-figure" } + +=== "<12>" + ![편집 거리 동적 계획법 12단계](edit_distance_problem.assets/edit_distance_dp_step12.png){ class="animation-figure" } + +=== "<13>" + ![편집 거리 동적 계획법 13단계](edit_distance_problem.assets/edit_distance_dp_step13.png){ class="animation-figure" } + +=== "<14>" + ![편집 거리 동적 계획법 14단계](edit_distance_problem.assets/edit_distance_dp_step14.png){ class="animation-figure" } + +=== "<15>" + ![편집 거리 동적 계획법 15단계](edit_distance_problem.assets/edit_distance_dp_step15.png){ class="animation-figure" } + +

그림 14-30   편집 거리의 동적 계획법 과정

+ +### 3.   공간 최적화 + +$dp[i,j]$는 위쪽 $dp[i-1, j]$, 왼쪽 $dp[i, j-1]$, 왼쪽 위 $dp[i-1, j-1]$에서 전이된다. 그런데 정방향 순회는 왼쪽 위 값 $dp[i-1, j-1]$을 잃고, 역방향 순회는 $dp[i, j-1]$을 미리 계산하지 못하므로 두 순서 모두 그대로는 사용할 수 없다. + +이를 위해 변수 `leftup`에 왼쪽 위의 해 $dp[i-1, j-1]$을 잠시 저장하면 왼쪽과 위쪽의 해만 고려하면 된다. 이때는 무한 배낭 문제와 같은 상황이므로 정방향 순회를 사용할 수 있다. 코드는 다음과 같다. + +=== "Python" + + ```python title="edit_distance.py" + def edit_distance_dp_comp(s: str, t: str) -> int: + """편집 거리: 공간을 최적화한 동적 계획법""" + n, m = len(s), len(t) + dp = [0] * (m + 1) + # 상태 전이: 첫 번째 행 + for j in range(1, m + 1): + dp[j] = j + # 상태 전이: 나머지 행 + for i in range(1, n + 1): + # 상태 전이: 첫 번째 열 + leftup = dp[0] # dp[i-1, j-1]을 임시로 저장한다 + dp[0] += 1 + # 상태 전이: 나머지 열 + for j in range(1, m + 1): + temp = dp[j] + if s[i - 1] == t[j - 1]: + # 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup + else: + # 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = min(dp[j - 1], dp[j], leftup) + 1 + leftup = temp # 다음 반복의 dp[i-1, j-1]로 갱신한다 + return dp[m] + ``` + +=== "C++" + + ```cpp title="edit_distance.cpp" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + int editDistanceDPComp(string s, string t) { + int n = s.length(), m = t.length(); + vector dp(m + 1, 0); + // 상태 전이: 첫 번째 행 + for (int j = 1; j <= m; j++) { + dp[j] = j; + } + // 상태 전이: 나머지 행 + for (int i = 1; i <= n; i++) { + // 상태 전이: 첫 번째 열 + int leftup = dp[0]; // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i; + // 상태 전이: 나머지 열 + for (int j = 1; j <= m; j++) { + int temp = dp[j]; + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = min(min(dp[j - 1], dp[j]), leftup) + 1; + } + leftup = temp; // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + return dp[m]; + } + ``` + +=== "Java" + + ```java title="edit_distance.java" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + int editDistanceDPComp(String s, String t) { + int n = s.length(), m = t.length(); + int[] dp = new int[m + 1]; + // 상태 전이: 첫 번째 행 + for (int j = 1; j <= m; j++) { + dp[j] = j; + } + // 상태 전이: 나머지 행 + for (int i = 1; i <= n; i++) { + // 상태 전이: 첫 번째 열 + int leftup = dp[0]; // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i; + // 상태 전이: 나머지 열 + for (int j = 1; j <= m; j++) { + int temp = dp[j]; + if (s.charAt(i - 1) == t.charAt(j - 1)) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = Math.min(Math.min(dp[j - 1], dp[j]), leftup) + 1; + } + leftup = temp; // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + return dp[m]; + } + ``` + +=== "C#" + + ```csharp title="edit_distance.cs" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + int EditDistanceDPComp(string s, string t) { + int n = s.Length, m = t.Length; + int[] dp = new int[m + 1]; + // 상태 전이: 첫 번째 행 + for (int j = 1; j <= m; j++) { + dp[j] = j; + } + // 상태 전이: 나머지 행 + for (int i = 1; i <= n; i++) { + // 상태 전이: 첫 번째 열 + int leftup = dp[0]; // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i; + // 상태 전이: 나머지 열 + for (int j = 1; j <= m; j++) { + int temp = dp[j]; + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = Math.Min(Math.Min(dp[j - 1], dp[j]), leftup) + 1; + } + leftup = temp; // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + return dp[m]; + } + ``` + +=== "Go" + + ```go title="edit_distance.go" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + func editDistanceDPComp(s string, t string) int { + n := len(s) + m := len(t) + dp := make([]int, m+1) + // 상태 전이: 첫 번째 행 + for j := 1; j <= m; j++ { + dp[j] = j + } + // 상태 전이: 나머지 행 + for i := 1; i <= n; i++ { + // 상태 전이: 첫 번째 열 + leftUp := dp[0] // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i + // 상태 전이: 나머지 열 + for j := 1; j <= m; j++ { + temp := dp[j] + if s[i-1] == t[j-1] { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftUp + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = MinInt(MinInt(dp[j-1], dp[j]), leftUp) + 1 + } + leftUp = temp // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + return dp[m] + } + ``` + +=== "Swift" + + ```swift title="edit_distance.swift" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + func editDistanceDPComp(s: String, t: String) -> Int { + let n = s.utf8CString.count + let m = t.utf8CString.count + var dp = Array(repeating: 0, count: m + 1) + // 상태 전이: 첫 번째 행 + for j in 1 ... m { + dp[j] = j + } + // 상태 전이: 나머지 행 + for i in 1 ... n { + // 상태 전이: 첫 번째 열 + var leftup = dp[0] // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i + // 상태 전이: 나머지 열 + for j in 1 ... m { + let temp = dp[j] + if s.utf8CString[i - 1] == t.utf8CString[j - 1] { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = min(min(dp[j - 1], dp[j]), leftup) + 1 + } + leftup = temp // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + return dp[m] + } + ``` + +=== "JS" + + ```javascript title="edit_distance.js" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + function editDistanceDPComp(s, t) { + const n = s.length, + m = t.length; + const dp = new Array(m + 1).fill(0); + // 상태 전이: 첫 번째 행 + for (let j = 1; j <= m; j++) { + dp[j] = j; + } + // 상태 전이: 나머지 행 + for (let i = 1; i <= n; i++) { + // 상태 전이: 첫 번째 열 + let leftup = dp[0]; // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i; + // 상태 전이: 나머지 열 + for (let j = 1; j <= m; j++) { + const temp = dp[j]; + if (s.charAt(i - 1) === t.charAt(j - 1)) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = Math.min(dp[j - 1], dp[j], leftup) + 1; + } + leftup = temp; // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + return dp[m]; + } + ``` + +=== "TS" + + ```typescript title="edit_distance.ts" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + function editDistanceDPComp(s: string, t: string): number { + const n = s.length, + m = t.length; + const dp = new Array(m + 1).fill(0); + // 상태 전이: 첫 번째 행 + for (let j = 1; j <= m; j++) { + dp[j] = j; + } + // 상태 전이: 나머지 행 + for (let i = 1; i <= n; i++) { + // 상태 전이: 첫 번째 열 + let leftup = dp[0]; // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i; + // 상태 전이: 나머지 열 + for (let j = 1; j <= m; j++) { + const temp = dp[j]; + if (s.charAt(i - 1) === t.charAt(j - 1)) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = Math.min(dp[j - 1], dp[j], leftup) + 1; + } + leftup = temp; // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + return dp[m]; + } + ``` + +=== "Dart" + + ```dart title="edit_distance.dart" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + int editDistanceDPComp(String s, String t) { + int n = s.length, m = t.length; + List dp = List.filled(m + 1, 0); + // 상태 전이: 첫 번째 행 + for (int j = 1; j <= m; j++) { + dp[j] = j; + } + // 상태 전이: 나머지 행 + for (int i = 1; i <= n; i++) { + // 상태 전이: 첫 번째 열 + int leftup = dp[0]; // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i; + // 상태 전이: 나머지 열 + for (int j = 1; j <= m; j++) { + int temp = dp[j]; + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = min(min(dp[j - 1], dp[j]), leftup) + 1; + } + leftup = temp; // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + return dp[m]; + } + ``` + +=== "Rust" + + ```rust title="edit_distance.rs" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + fn edit_distance_dp_comp(s: &str, t: &str) -> i32 { + let (n, m) = (s.len(), t.len()); + let mut dp = vec![0; m + 1]; + // 상태 전이: 첫 번째 행 + for j in 1..m { + dp[j] = j as i32; + } + // 상태 전이: 나머지 행 + for i in 1..=n { + // 상태 전이: 첫 번째 열 + let mut leftup = dp[0]; // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i as i32; + // 상태 전이: 나머지 열 + for j in 1..=m { + let temp = dp[j]; + if s.chars().nth(i - 1) == t.chars().nth(j - 1) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = std::cmp::min(std::cmp::min(dp[j - 1], dp[j]), leftup) + 1; + } + leftup = temp; // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + dp[m] + } + ``` + +=== "C" + + ```c title="edit_distance.c" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + int editDistanceDPComp(char *s, char *t, int n, int m) { + int *dp = calloc(m + 1, sizeof(int)); + // 상태 전이: 첫 번째 행 + for (int j = 1; j <= m; j++) { + dp[j] = j; + } + // 상태 전이: 나머지 행 + for (int i = 1; i <= n; i++) { + // 상태 전이: 첫 번째 열 + int leftup = dp[0]; // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i; + // 상태 전이: 나머지 열 + for (int j = 1; j <= m; j++) { + int temp = dp[j]; + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup; + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = myMin(myMin(dp[j - 1], dp[j]), leftup) + 1; + } + leftup = temp; // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + int res = dp[m]; + // 메모리 해제 + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="edit_distance.kt" + /* 편집 거리: 공간을 최적화한 동적 계획법 */ + fun editDistanceDPComp(s: String, t: String): Int { + val n = s.length + val m = t.length + val dp = IntArray(m + 1) + // 상태 전이: 첫 번째 행 + for (j in 1..m) { + dp[j] = j + } + // 상태 전이: 나머지 행 + for (i in 1..n) { + // 상태 전이: 첫 번째 열 + var leftup = dp[0] // dp[i-1, j-1]을 임시로 저장한다 + dp[0] = i + // 상태 전이: 나머지 열 + for (j in 1..m) { + val temp = dp[j] + if (s[i - 1] == t[j - 1]) { + // 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup + } else { + // 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = min(min(dp[j - 1], dp[j]), leftup) + 1 + } + leftup = temp // 다음 반복의 dp[i-1, j-1]로 갱신한다 + } + } + return dp[m] + } + ``` + +=== "Ruby" + + ```ruby title="edit_distance.rb" + ### 편집 거리: 공간을 최적화한 동적 계획법 ### + def edit_distance_dp_comp(s, t) + n, m = s.length, t.length + dp = Array.new(m + 1, 0) + # 상태 전이: 첫 번째 행 + (1...(m + 1)).each { |j| dp[j] = j } + # 상태 전이: 나머지 행 + for i in 1...(n + 1) + # 상태 전이: 첫 번째 열 + leftup = dp.first # dp[i-1, j-1]을 임시로 저장한다 + dp[0] += 1 + # 상태 전이: 나머지 열 + for j in 1...(m + 1) + temp = dp[j] + if s[i - 1] == t[j - 1] + # 두 문자가 같으면 두 문자를 바로 건너뛴다 + dp[j] = leftup + else + # 최소 편집 횟수 = 삽입, 삭제, 교체 세 연산에 대한 최소 편집 횟수 + 1 + dp[j] = [dp[j - 1], dp[j], leftup].min + 1 + end + leftup = temp # 다음 반복의 dp[i-1, j-1]로 갱신한다 + end + end + dp[m] + end + ``` + +??? pythontutor "코드 시각화" + +
+ diff --git a/ko/docs/chapter_dynamic_programming/exercises.md b/ko/docs/chapter_dynamic_programming/exercises.md new file mode 100644 index 000000000..4fd9b4f31 --- /dev/null +++ b/ko/docs/chapter_dynamic_programming/exercises.md @@ -0,0 +1,115 @@ +--- +comments: true +--- + +# 14.8   연습 문제 + +## 14.8.1   개념 확인 + +### 1.   언제 동적 계획법을 사용하는 것이 적합한가 + +한 학생이 “점화식을 쓸 수만 있으면 동적 계획법을 사용해야 한다.”라고 말했다. +다음 세 작업에 동적 계획법, 백트래킹, 또는 `dp` 테이블을 만들지 않는 반복문이나 수학 공식 중 어느 방법이 더 적합한지 판단하고, 핵심 이유를 하나씩 적어 보자. + + + +1. 액면가가 `[1, 3, 4]`인 동전으로 금액 6을 만들 때 필요한 동전의 최소 개수를 구한다. 각 동전은 반복해서 사용할 수 있다. +2. `[1, 2, 3]`의 모든 순열을 출력한다. +3. $1 + 2 + \dots + n$을 계산한다. + + 동적 계획법이 적합하다고 판단한 작업에서는 `dp[i]`가 무엇을 나타내는지도 설명하라. + +??? success "참고 답안" + + 1. 동적 계획법이 적합하다. `dp[i]`를 금액 `i`를 만드는 데 필요한 동전의 최소 개수로 정의한다. + `i` 이하인 각 동전 `c`에 대해 `dp[i-c] + 1`을 후보 답으로 삼고, + 이 후보들 중 최솟값을 선택하면 된다. 서로 다른 선택에서 같은 금액을 반복해서 만나며, 더 큰 금액의 최적해도 더 작은 금액의 최적해로 구성할 수 있다. + 금액 6의 답은 2이며, `3 + 3`으로 만들 수 있다. + + 2. 백트래킹을 사용해야 한다. 문제에서는 순열 6개를 모두 하나씩 생성해야 한다. 백트래킹은 하나를 선택하고 탐색을 이어 간 뒤, + 선택을 취소하고 다른 분기를 시도하는 과정을 체계적으로 수행할 수 있다. 어떤 방법을 쓰더라도 이 순열들을 실제로 출력하려면 열거 과정을 생략할 수 없다. + + 3. 반복문이나 등차수열의 합 공식을 사용하면 된다. `S(i) = S(i-1) + i`라는 점화식을 쓸 수는 있지만, `S(i)`를 계산할 때는 더 작은 값 `S(i-1)` 하나에만 의존한다. + 각 부분합을 한 번만 계산하면 되고 중복되는 부분 문제가 없으므로 `dp` 테이블을 만들 필요가 없다. ‘점화식을 쓸 수 있다’고 해서 ‘동적 계획법이 필요하다’는 뜻은 아니다. + +### 2.   배낭 테이블의 한 칸은 어떻게 계산하는가 + +0-1 배낭 문제에서 물건의 무게는 `wgt = [1, 2, 3]`, 가치는 `val = [5, 11, 15]`이며, 배낭 용량은 4다. +`dp[i][c]`는 앞의 물건 $i$개만 고려하고 배낭 용량의 상한이 $c$일 때 얻을 수 있는 최대 가치를 나타낸다. +배낭을 반드시 가득 채울 필요는 없다. + +지금은 상태 `dp[3][4]`만 계산한다. `dp[2][4] = 16`, `dp[2][1] = 5`임을 알고 있다. + + + +1. 세 번째 물건을 선택하지 않을 때 후보 가치는 얼마인가? +2. 세 번째 물건을 선택하면 용량이 얼마나 남으며, 후보 가치는 얼마인가? +3. `dp[3][4]`는 얼마로 정해야 하는가? 이는 어떤 물건들을 선택한 결과인가? + +??? success "참고 답안" + + 1. 세 번째 물건을 선택하지 않으면 앞의 두 물건에 대한 결과를 그대로 사용하므로 후보 가치는 `dp[2][4] = 16`이다. + + 2. 세 번째 물건의 무게는 3이므로 넣은 뒤 남는 용량은 $4-3=1$이다. 후보 가치는 + `dp[2][1] + 15 = 5 + 15 = 20`이다. + + 3. 16과 20을 비교하면 `dp[3][4] = 20`을 얻는다. 이는 첫 번째와 세 번째 물건을 선택한 결과로, + 총무게는 $1+3=4$, 총가치는 $5+15=20$이다. + + 이 상태의 계산은 0-1 배낭 문제에서 한 번의 ‘선택하거나 선택하지 않거나’ 비교를 보여 준다. + +### 3.   배낭 용량은 어떤 순서로 갱신해야 하는가 + +0-1 배낭 문제에 물건이 하나뿐이며, 무게는 2, 가치는 5다. 배낭 용량은 4다. +각 물건은 최대 한 번만 선택할 수 있고, 초기 일차원 배열은 `dp = [0, 0, 0, 0, 0]`이다. + +한 학생이 이 물건을 처리하면서 용량을 2부터 4까지 갱신했다. + +- `dp[2]`를 갱신한 뒤 5를 얻었다. +- `dp[3]`을 갱신한 뒤에도 5를 얻었다. +- `dp[4]`를 갱신할 때 방금 구한 `dp[2]`를 다시 사용하여 `dp[4] = 10`을 얻었다. + + + +1. `dp[4] = 10`이라는 결과가 올바른가? 이유는 무엇인가? +2. ‘각 물건은 최대 한 번만 선택할 수 있다’는 조건에 따르면 `dp[4]`는 얼마여야 하는가? +3. 각 물건을 처리할 때 용량을 큰 것부터 작은 것 순으로 갱신해야 하는가, 그 반대인가? 그렇게 하면 어떤 문제를 피할 수 있는가? + +??? success "참고 답안" + + 1. 올바르지 않다. 가치 10은 가치가 5인 이 물건을 두 번 넣은 것과 같으므로, + ‘각 물건은 최대 한 번만 선택할 수 있다’는 조건에 어긋난다. + + 2. 배낭에는 이 물건 하나만 넣을 수 있으므로 올바른 `dp[4]` 값은 5다. + + 3. 용량을 큰 것부터 작은 것 순으로, 즉 4, 3, 2 순으로 갱신해야 한다. + 그러면 `dp[c]`를 계산할 때 읽는 `dp[c-2]`는 아직 현재 물건을 처리하기 전의 결과이므로, + 같은 회전에서 현재 물건을 반복해서 사용하지 않게 된다. + +## 14.8.2   프로그래밍 연습 + +### 1.   계단을 오르는 방법의 수 + +계단은 총 `n`칸이다. 한 번에 1칸 또는 2칸만 올라갈 수 있으며, 정확히 `n`번째 칸에 도착해야 한다. +서로 다른 방법이 모두 몇 가지인지 계산하라. `n >= 1`이며, 각 걸음에서 1칸을 오르는지 2칸을 오르는지에 따라서만 방법을 구분한다. +일차원 동적 계획법 배열을 사용하라. 상태 두 개만 유지하는 공간 최적화 방식은 아직 사용하지 마라. + +??? tip "풀이 힌트" + + 1. i번째 칸에 도착하는 마지막 걸음은 1칸 또는 2칸을 오르는 것뿐이다. + 2. 따라서 dp[i] = dp[i-1] + dp[i-2]다. + 3. n이 1과 2인 경우를 먼저 처리한 뒤, 세 번째 칸부터 테이블을 채운다. + +[LeetCode](https://leetcode.com/problems/climbing-stairs/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } + +### 2.   0-1 배낭 문제 + +길이가 같은 배열 `wgt`와 `val`이 주어진다. `i`번째 물건의 무게 `wgt[i]`는 양의 정수이고, 가치 `val[i]`는 음이 아닌 정수다. +배낭 용량 `cap`은 음이 아닌 정수다. 각 물건은 최대 한 번만 선택할 수 있다. 총무게가 `cap`을 넘지 않는 조건에서 +배낭에 넣을 수 있는 최대 총가치를 구하라. 일차원 동적 계획법으로 구현하라. + +??? tip "풀이 힌트" + + 1. 길이가 cap + 1인 배열 dp를 초기화한다. dp[c]는 용량 상한이 c일 때의 최대 가치를 나타낸다. + 2. 물건 i를 처리할 때 ‘선택하지 않는 경우’의 dp[c]와 ‘선택하는 경우’의 dp[c-wgt[i]] + val[i]를 비교한다. + 3. 같은 회전에서 현재 물건을 반복해서 선택하지 않도록 용량을 반드시 큰 것부터 작은 것 순으로 갱신해야 한다. diff --git a/ko/docs/chapter_dynamic_programming/index.md b/ko/docs/chapter_dynamic_programming/index.md new file mode 100644 index 000000000..70408d1a6 --- /dev/null +++ b/ko/docs/chapter_dynamic_programming/index.md @@ -0,0 +1,25 @@ +--- +comments: true +icon: material/table-pivot +--- + +# 제 14 장   동적 계획법 + +![동적 계획법](../assets/covers/chapter_dynamic_programming.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 시냇물은 강으로 모이고, 강물은 바다로 흘러든다. + + 동적 계획법은 작은 문제의 해를 모아 큰 문제의 답을 만들며, 한 걸음씩 문제 해결의 건너편으로 우리를 이끈다. + +## 이 장의 내용 + +- [14.1   동적 계획법 첫걸음](intro_to_dynamic_programming.md) +- [14.2   동적 계획법 문제의 특성](dp_problem_features.md) +- [14.3   동적 계획법 문제 풀이 방법](dp_solution_pipeline.md) +- [14.4   0-1 배낭 문제](knapsack_problem.md) +- [14.5   무한 배낭 문제](unbounded_knapsack_problem.md) +- [14.6   편집 거리 문제](edit_distance_problem.md) +- [14.7   요약](summary.md) +- [14.8   연습 문제](exercises.md) diff --git a/ko/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md b/ko/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md new file mode 100644 index 000000000..ab8d89c4b --- /dev/null +++ b/ko/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -0,0 +1,1585 @@ +--- +comments: true +--- + +# 14.1   동적 계획법 첫걸음 + +동적 계획법(dynamic programming)은 중요한 알고리즘 패러다임이다. 문제를 더 작은 부분 문제들로 나누고 그 해를 저장하여 중복 계산을 피함으로써 시간 효율을 크게 높인다. + +이 절에서는 대표적인 예제에서 출발하여 먼저 완전 탐색을 수행하는 백트래킹 풀이를 제시한다. 그 안의 중복 부분 문제를 살펴보고, 더 효율적인 동적 계획법 풀이를 단계적으로 도출한다. + +!!! question "계단 오르기" + + 총 $n$ 칸인 계단이 있고, 한 번에 $1$ 칸 또는 $2$ 칸을 오를 수 있다. 맨 위까지 오르는 방법은 몇 가지인가? + +아래 그림(14-1)과 같이 $3$ 칸짜리 계단의 맨 위에 오르는 방법은 총 $3$ 가지다. + +![3번째 칸까지 오르는 방법 수](intro_to_dynamic_programming.assets/climbing_stairs_example.png){ class="animation-figure" } + +

그림 14-1   3번째 칸까지 오르는 방법 수

+ +이 문제는 방법의 수를 구하는 것이므로 **백트래킹으로 모든 가능성을 열거할 수 있다**. 계단 오르기를 여러 차례의 선택 과정으로 생각해 보자. 바닥에서 출발하여 매번 $1$ 칸 또는 $2$ 칸을 고른다. 맨 위에 도착할 때마다 방법 수를 $1$ 늘리고, 맨 위를 넘어가면 가지치기한다. 코드는 다음과 같다. + +=== "Python" + + ```python title="climbing_stairs_backtrack.py" + def backtrack(choices: list[int], state: int, n: int, res: list[int]) -> int: + """백트래킹""" + # n번째 계단에 도달하면 방법의 수에 1을 더한다 + if state == n: + res[0] += 1 + # 모든 선택지를 순회한다 + for choice in choices: + # 가지치기: n번째 계단을 넘어갈 수 없다 + if state + choice > n: + continue + # 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res) + # 되돌리기 + + def climbing_stairs_backtrack(n: int) -> int: + """계단 오르기: 백트래킹""" + choices = [1, 2] # 한 계단 또는 두 계단 오르기를 선택할 수 있다 + state = 0 # 0번째 계단에서 오르기 시작한다 + res = [0] # res[0]에 방법의 수를 기록한다 + backtrack(choices, state, n, res) + return res[0] + ``` + +=== "C++" + + ```cpp title="climbing_stairs_backtrack.cpp" + /* 백트래킹 */ + void backtrack(vector &choices, int state, int n, vector &res) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if (state == n) + res[0]++; + // 모든 선택지를 순회한다 + for (auto &choice : choices) { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if (state + choice > n) + continue; + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res); + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + int climbingStairsBacktrack(int n) { + vector choices = {1, 2}; // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + int state = 0; // 0번째 계단에서 오르기 시작한다 + vector res = {0}; // res[0]에 방법의 수를 기록한다 + backtrack(choices, state, n, res); + return res[0]; + } + ``` + +=== "Java" + + ```java title="climbing_stairs_backtrack.java" + /* 백트래킹 */ + void backtrack(List choices, int state, int n, List res) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if (state == n) + res.set(0, res.get(0) + 1); + // 모든 선택지를 순회한다 + for (Integer choice : choices) { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if (state + choice > n) + continue; + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res); + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + int climbingStairsBacktrack(int n) { + List choices = Arrays.asList(1, 2); // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + int state = 0; // 0번째 계단에서 오르기 시작한다 + List res = new ArrayList<>(); + res.add(0); // res[0]에 방법의 수를 기록한다 + backtrack(choices, state, n, res); + return res.get(0); + } + ``` + +=== "C#" + + ```csharp title="climbing_stairs_backtrack.cs" + /* 백트래킹 */ + void Backtrack(List choices, int state, int n, List res) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if (state == n) + res[0]++; + // 모든 선택지를 순회한다 + foreach (int choice in choices) { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if (state + choice > n) + continue; + // 시도: 선택하고 상태를 갱신한다 + Backtrack(choices, state + choice, n, res); + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + int ClimbingStairsBacktrack(int n) { + List choices = [1, 2]; // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + int state = 0; // 0번째 계단에서 오르기 시작한다 + List res = [0]; // res[0]에 방법의 수를 기록한다 + Backtrack(choices, state, n, res); + return res[0]; + } + ``` + +=== "Go" + + ```go title="climbing_stairs_backtrack.go" + /* 백트래킹 */ + func backtrack(choices []int, state, n int, res []int) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if state == n { + res[0] = res[0] + 1 + } + // 모든 선택지를 순회한다 + for _, choice := range choices { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if state+choice > n { + continue + } + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state+choice, n, res) + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + func climbingStairsBacktrack(n int) int { + // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + choices := []int{1, 2} + // 0번째 계단에서 오르기 시작한다 + state := 0 + res := make([]int, 1) + // res[0]에 방법의 수를 기록한다 + res[0] = 0 + backtrack(choices, state, n, res) + return res[0] + } + ``` + +=== "Swift" + + ```swift title="climbing_stairs_backtrack.swift" + /* 백트래킹 */ + func backtrack(choices: [Int], state: Int, n: Int, res: inout [Int]) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if state == n { + res[0] += 1 + } + // 모든 선택지를 순회한다 + for choice in choices { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if state + choice > n { + continue + } + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices: choices, state: state + choice, n: n, res: &res) + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + func climbingStairsBacktrack(n: Int) -> Int { + let choices = [1, 2] // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + let state = 0 // 0번째 계단에서 오르기 시작한다 + var res: [Int] = [] + res.append(0) // res[0]에 방법의 수를 기록한다 + backtrack(choices: choices, state: state, n: n, res: &res) + return res[0] + } + ``` + +=== "JS" + + ```javascript title="climbing_stairs_backtrack.js" + /* 백트래킹 */ + function backtrack(choices, state, n, res) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if (state === n) res.set(0, res.get(0) + 1); + // 모든 선택지를 순회한다 + for (const choice of choices) { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if (state + choice > n) continue; + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res); + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + function climbingStairsBacktrack(n) { + const choices = [1, 2]; // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + const state = 0; // 0번째 계단에서 오르기 시작한다 + const res = new Map(); + res.set(0, 0); // res[0]에 방법의 수를 기록한다 + backtrack(choices, state, n, res); + return res.get(0); + } + ``` + +=== "TS" + + ```typescript title="climbing_stairs_backtrack.ts" + /* 백트래킹 */ + function backtrack( + choices: number[], + state: number, + n: number, + res: Map<0, any> + ): void { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if (state === n) res.set(0, res.get(0) + 1); + // 모든 선택지를 순회한다 + for (const choice of choices) { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if (state + choice > n) continue; + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res); + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + function climbingStairsBacktrack(n: number): number { + const choices = [1, 2]; // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + const state = 0; // 0번째 계단에서 오르기 시작한다 + const res = new Map(); + res.set(0, 0); // res[0]에 방법의 수를 기록한다 + backtrack(choices, state, n, res); + return res.get(0); + } + ``` + +=== "Dart" + + ```dart title="climbing_stairs_backtrack.dart" + /* 백트래킹 */ + void backtrack(List choices, int state, int n, List res) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if (state == n) { + res[0]++; + } + // 모든 선택지를 순회한다 + for (int choice in choices) { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if (state + choice > n) continue; + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res); + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + int climbingStairsBacktrack(int n) { + List choices = [1, 2]; // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + int state = 0; // 0번째 계단에서 오르기 시작한다 + List res = []; + res.add(0); // res[0]에 방법의 수를 기록한다 + backtrack(choices, state, n, res); + return res[0]; + } + ``` + +=== "Rust" + + ```rust title="climbing_stairs_backtrack.rs" + /* 백트래킹 */ + fn backtrack(choices: &[i32], state: i32, n: i32, res: &mut [i32]) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if state == n { + res[0] = res[0] + 1; + } + // 모든 선택지를 순회한다 + for &choice in choices { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if state + choice > n { + continue; + } + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res); + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + fn climbing_stairs_backtrack(n: usize) -> i32 { + let choices = vec![1, 2]; // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + let state = 0; // 0번째 계단에서 오르기 시작한다 + let mut res = Vec::new(); + res.push(0); // res[0]에 방법의 수를 기록한다 + backtrack(&choices, state, n as i32, &mut res); + res[0] + } + ``` + +=== "C" + + ```c title="climbing_stairs_backtrack.c" + /* 백트래킹 */ + void backtrack(int *choices, int state, int n, int *res, int len) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if (state == n) + res[0]++; + // 모든 선택지를 순회한다 + for (int i = 0; i < len; i++) { + int choice = choices[i]; + // 가지치기: n번째 계단을 넘어갈 수 없다 + if (state + choice > n) + continue; + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res, len); + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + int climbingStairsBacktrack(int n) { + int choices[2] = {1, 2}; // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + int state = 0; // 0번째 계단에서 오르기 시작한다 + int *res = (int *)malloc(sizeof(int)); + *res = 0; // res[0]에 방법의 수를 기록한다 + int len = sizeof(choices) / sizeof(int); + backtrack(choices, state, n, res, len); + int result = *res; + free(res); + return result; + } + ``` + +=== "Kotlin" + + ```kotlin title="climbing_stairs_backtrack.kt" + /* 백트래킹 */ + fun backtrack( + choices: MutableList, + state: Int, + n: Int, + res: MutableList + ) { + // n번째 계단에 도달하면 방법의 수에 1을 더한다 + if (state == n) + res[0] = res[0] + 1 + // 모든 선택지를 순회한다 + for (choice in choices) { + // 가지치기: n번째 계단을 넘어갈 수 없다 + if (state + choice > n) continue + // 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res) + // 되돌리기 + } + } + + /* 계단 오르기: 백트래킹 */ + fun climbingStairsBacktrack(n: Int): Int { + val choices = mutableListOf(1, 2) // 한 계단 또는 두 계단 오르기를 선택할 수 있다 + val state = 0 // 0번째 계단에서 오르기 시작한다 + val res = mutableListOf() + res.add(0) // res[0]에 방법의 수를 기록한다 + backtrack(choices, state, n, res) + return res[0] + } + ``` + +=== "Ruby" + + ```ruby title="climbing_stairs_backtrack.rb" + ### 백트래킹 ### + def backtrack(choices, state, n, res) + # n번째 계단에 도달하면 방법의 수에 1을 더한다 + res[0] += 1 if state == n + # 모든 선택지를 순회한다 + for choice in choices + # 가지치기: n번째 계단을 넘어갈 수 없다 + next if state + choice > n + + # 시도: 선택하고 상태를 갱신한다 + backtrack(choices, state + choice, n, res) + end + # 되돌리기 + end + + ### 계단 오르기: 백트래킹 ### + def climbing_stairs_backtrack(n) + choices = [1, 2] # 한 계단 또는 두 계단 오르기를 선택할 수 있다 + state = 0 # 0번째 계단에서 오르기 시작한다 + res = [0] # res[0]에 방법의 수를 기록한다 + backtrack(choices, state, n, res) + res.first + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 14.1.1   방법 1: 완전 탐색 + +백트래킹은 보통 문제를 명시적으로 나누지 않고 일련의 의사 결정 단계로 바라보며, 시도와 가지치기를 통해 가능한 모든 해를 찾는다. + +이 문제를 부분 문제로 나누는 관점에서 분석해 보자. $i$ 번째 칸에 오르는 방법 수를 $dp[i]$라고 하면 $dp[i]$가 원래 문제이고, 다음은 그 부분 문제들이다. + +$$ +dp[i-1], dp[i-2], \dots, dp[2], dp[1] +$$ + +한 번에 $1$ 칸 또는 $2$ 칸만 오를 수 있으므로, 현재 $i$ 번째 칸에 있다면 직전에는 $i - 1$ 번째 또는 $i - 2$ 번째 칸에 있었어야 한다. 즉, $i -1$ 번째 또는 $i - 2$ 번째 칸에서만 $i$ 번째 칸으로 올라올 수 있다. + +여기서 중요한 결론을 얻는다. **$i - 1$ 번째 칸에 오르는 방법 수와 $i - 2$ 번째 칸에 오르는 방법 수를 더하면 $i$ 번째 칸에 오르는 방법 수가 된다**. 식으로 나타내면 다음과 같다. + +$$ +dp[i] = dp[i-1] + dp[i-2] +$$ + +이는 계단 오르기 문제의 부분 문제들 사이에 점화 관계가 있으며, **부분 문제의 해로 원래 문제의 해를 구성할 수 있다는 뜻**이다. 아래 그림(14-2)이 이 점화 관계를 보여 준다. + +![방법 수의 점화 관계](intro_to_dynamic_programming.assets/climbing_stairs_state_transfer.png){ class="animation-figure" } + +

그림 14-2   방법 수의 점화 관계

+ +점화식으로 완전 탐색 풀이를 얻을 수 있다. $dp[n]$에서 출발해 **큰 문제 하나를 더 작은 두 문제의 합으로 재귀적으로 나누고**, 가장 작은 부분 문제 $dp[1]$과 $dp[2]$에 도달하면 반환한다. 이들의 해는 $dp[1] = 1$, $dp[2] = 2$로 이미 알려져 있다. 즉, $1$, $2$ 번째 칸에 오르는 방법은 각각 $1$, $2$ 가지다. + +아래 코드는 일반적인 백트래킹 코드와 마찬가지로 깊이 우선 탐색이지만 더 간결하다. + +=== "Python" + + ```python title="climbing_stairs_dfs.py" + def dfs(i: int) -> int: + """탐색""" + # dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if i == 1 or i == 2: + return i + # dp[i] = dp[i-1] + dp[i-2] + count = dfs(i - 1) + dfs(i - 2) + return count + + def climbing_stairs_dfs(n: int) -> int: + """계단 오르기: 탐색""" + return dfs(n) + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dfs.cpp" + /* 탐색 */ + int dfs(int i) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) + return i; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 계단 오르기: 탐색 */ + int climbingStairsDFS(int n) { + return dfs(n); + } + ``` + +=== "Java" + + ```java title="climbing_stairs_dfs.java" + /* 탐색 */ + int dfs(int i) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) + return i; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 계단 오르기: 탐색 */ + int climbingStairsDFS(int n) { + return dfs(n); + } + ``` + +=== "C#" + + ```csharp title="climbing_stairs_dfs.cs" + /* 탐색 */ + int DFS(int i) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) + return i; + // dp[i] = dp[i-1] + dp[i-2] + int count = DFS(i - 1) + DFS(i - 2); + return count; + } + + /* 계단 오르기: 탐색 */ + int ClimbingStairsDFS(int n) { + return DFS(n); + } + ``` + +=== "Go" + + ```go title="climbing_stairs_dfs.go" + /* 탐색 */ + func dfs(i int) int { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if i == 1 || i == 2 { + return i + } + // dp[i] = dp[i-1] + dp[i-2] + count := dfs(i-1) + dfs(i-2) + return count + } + + /* 계단 오르기: 탐색 */ + func climbingStairsDFS(n int) int { + return dfs(n) + } + ``` + +=== "Swift" + + ```swift title="climbing_stairs_dfs.swift" + /* 탐색 */ + func dfs(i: Int) -> Int { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if i == 1 || i == 2 { + return i + } + // dp[i] = dp[i-1] + dp[i-2] + let count = dfs(i: i - 1) + dfs(i: i - 2) + return count + } + + /* 계단 오르기: 탐색 */ + func climbingStairsDFS(n: Int) -> Int { + dfs(i: n) + } + ``` + +=== "JS" + + ```javascript title="climbing_stairs_dfs.js" + /* 탐색 */ + function dfs(i) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i === 1 || i === 2) return i; + // dp[i] = dp[i-1] + dp[i-2] + const count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 계단 오르기: 탐색 */ + function climbingStairsDFS(n) { + return dfs(n); + } + ``` + +=== "TS" + + ```typescript title="climbing_stairs_dfs.ts" + /* 탐색 */ + function dfs(i: number): number { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i === 1 || i === 2) return i; + // dp[i] = dp[i-1] + dp[i-2] + const count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 계단 오르기: 탐색 */ + function climbingStairsDFS(n: number): number { + return dfs(n); + } + ``` + +=== "Dart" + + ```dart title="climbing_stairs_dfs.dart" + /* 탐색 */ + int dfs(int i) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) return i; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 계단 오르기: 탐색 */ + int climbingStairsDFS(int n) { + return dfs(n); + } + ``` + +=== "Rust" + + ```rust title="climbing_stairs_dfs.rs" + /* 탐색 */ + fn dfs(i: usize) -> i32 { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if i == 1 || i == 2 { + return i as i32; + } + // dp[i] = dp[i-1] + dp[i-2] + let count = dfs(i - 1) + dfs(i - 2); + count + } + + /* 계단 오르기: 탐색 */ + fn climbing_stairs_dfs(n: usize) -> i32 { + dfs(n) + } + ``` + +=== "C" + + ```c title="climbing_stairs_dfs.c" + /* 탐색 */ + int dfs(int i) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) + return i; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 계단 오르기: 탐색 */ + int climbingStairsDFS(int n) { + return dfs(n); + } + ``` + +=== "Kotlin" + + ```kotlin title="climbing_stairs_dfs.kt" + /* 탐색 */ + fun dfs(i: Int): Int { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) return i + // dp[i] = dp[i-1] + dp[i-2] + val count = dfs(i - 1) + dfs(i - 2) + return count + } + + /* 계단 오르기: 탐색 */ + fun climbingStairsDFS(n: Int): Int { + return dfs(n) + } + ``` + +=== "Ruby" + + ```ruby title="climbing_stairs_dfs.rb" + ### 탐색 ### + def dfs(i) + # dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + return i if i == 1 || i == 2 + # dp[i] = dp[i-1] + dp[i-2] + dfs(i - 1) + dfs(i - 2) + end + + ### 계단 오르기: 탐색 ### + def climbing_stairs_dfs(n) + dfs(n) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-3)은 완전 탐색으로 만들어지는 재귀 트리다. 문제 $dp[n]$의 재귀 트리 깊이는 $n$이고 시간 복잡도는 $O(2^n)$이다. 지수적 증가는 폭발적이므로 비교적 큰 $n$을 입력하면 매우 오래 기다려야 한다. + +![계단 오르기의 재귀 트리](intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png){ class="animation-figure" } + +

그림 14-3   계단 오르기의 재귀 트리

+ +위 그림(14-3)에서 **지수 시간 복잡도는 '중복 부분 문제' 때문에 생긴다**. 예를 들어 $dp[9]$는 $dp[8]$과 $dp[7]$로, $dp[8]$은 $dp[7]$과 $dp[6]$으로 나뉘므로 두 경우 모두 부분 문제 $dp[7]$을 포함한다. + +그 아래의 부분 문제에도 더 작은 중복 부분 문제가 계속 생겨난다. 계산 자원의 대부분은 이러한 중복 부분 문제에 낭비된다. + +## 14.1.2   방법 2: 메모이제이션을 이용한 탐색 + +효율을 높이려면 **모든 중복 부분 문제를 한 번씩만 계산해야 한다**. 이를 위해 각 부분 문제의 해를 기록하는 배열 `mem`을 만들고, 탐색 중 중복 부분 문제를 가지치기한다. + +1. $dp[i]$를 처음 계산하면 나중에 사용할 수 있도록 `mem[i]`에 기록한다. +2. $dp[i]$가 다시 필요하면 `mem[i]`에서 결과를 바로 가져와 중복 계산을 피한다. + +코드는 다음과 같다. + +=== "Python" + + ```python title="climbing_stairs_dfs_mem.py" + def dfs(i: int, mem: list[int]) -> int: + """메모이제이션을 이용한 탐색""" + # dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if i == 1 or i == 2: + return i + # dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if mem[i] != -1: + return mem[i] + # dp[i] = dp[i-1] + dp[i-2] + count = dfs(i - 1, mem) + dfs(i - 2, mem) + # dp[i]를 기록한다 + mem[i] = count + return count + + def climbing_stairs_dfs_mem(n: int) -> int: + """계단 오르기: 메모이제이션을 이용한 탐색""" + # mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + mem = [-1] * (n + 1) + return dfs(n, mem) + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dfs_mem.cpp" + /* 메모이제이션을 이용한 탐색 */ + int dfs(int i, vector &mem) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) + return i; + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if (mem[i] != -1) + return mem[i]; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1, mem) + dfs(i - 2, mem); + // dp[i]를 기록한다 + mem[i] = count; + return count; + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + int climbingStairsDFSMem(int n) { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + vector mem(n + 1, -1); + return dfs(n, mem); + } + ``` + +=== "Java" + + ```java title="climbing_stairs_dfs_mem.java" + /* 메모이제이션을 이용한 탐색 */ + int dfs(int i, int[] mem) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) + return i; + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if (mem[i] != -1) + return mem[i]; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1, mem) + dfs(i - 2, mem); + // dp[i]를 기록한다 + mem[i] = count; + return count; + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + int climbingStairsDFSMem(int n) { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + int[] mem = new int[n + 1]; + Arrays.fill(mem, -1); + return dfs(n, mem); + } + ``` + +=== "C#" + + ```csharp title="climbing_stairs_dfs_mem.cs" + /* 메모이제이션을 이용한 탐색 */ + int DFS(int i, int[] mem) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) + return i; + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if (mem[i] != -1) + return mem[i]; + // dp[i] = dp[i-1] + dp[i-2] + int count = DFS(i - 1, mem) + DFS(i - 2, mem); + // dp[i]를 기록한다 + mem[i] = count; + return count; + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + int ClimbingStairsDFSMem(int n) { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + int[] mem = new int[n + 1]; + Array.Fill(mem, -1); + return DFS(n, mem); + } + ``` + +=== "Go" + + ```go title="climbing_stairs_dfs_mem.go" + /* 메모이제이션을 이용한 탐색 */ + func dfsMem(i int, mem []int) int { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if i == 1 || i == 2 { + return i + } + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if mem[i] != -1 { + return mem[i] + } + // dp[i] = dp[i-1] + dp[i-2] + count := dfsMem(i-1, mem) + dfsMem(i-2, mem) + // dp[i]를 기록한다 + mem[i] = count + return count + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + func climbingStairsDFSMem(n int) int { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + mem := make([]int, n+1) + for i := range mem { + mem[i] = -1 + } + return dfsMem(n, mem) + } + ``` + +=== "Swift" + + ```swift title="climbing_stairs_dfs_mem.swift" + /* 메모이제이션을 이용한 탐색 */ + func dfs(i: Int, mem: inout [Int]) -> Int { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if i == 1 || i == 2 { + return i + } + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if mem[i] != -1 { + return mem[i] + } + // dp[i] = dp[i-1] + dp[i-2] + let count = dfs(i: i - 1, mem: &mem) + dfs(i: i - 2, mem: &mem) + // dp[i]를 기록한다 + mem[i] = count + return count + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + func climbingStairsDFSMem(n: Int) -> Int { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + var mem = Array(repeating: -1, count: n + 1) + return dfs(i: n, mem: &mem) + } + ``` + +=== "JS" + + ```javascript title="climbing_stairs_dfs_mem.js" + /* 메모이제이션을 이용한 탐색 */ + function dfs(i, mem) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i === 1 || i === 2) return i; + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if (mem[i] != -1) return mem[i]; + // dp[i] = dp[i-1] + dp[i-2] + const count = dfs(i - 1, mem) + dfs(i - 2, mem); + // dp[i]를 기록한다 + mem[i] = count; + return count; + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + function climbingStairsDFSMem(n) { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + const mem = new Array(n + 1).fill(-1); + return dfs(n, mem); + } + ``` + +=== "TS" + + ```typescript title="climbing_stairs_dfs_mem.ts" + /* 메모이제이션을 이용한 탐색 */ + function dfs(i: number, mem: number[]): number { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i === 1 || i === 2) return i; + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if (mem[i] != -1) return mem[i]; + // dp[i] = dp[i-1] + dp[i-2] + const count = dfs(i - 1, mem) + dfs(i - 2, mem); + // dp[i]를 기록한다 + mem[i] = count; + return count; + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + function climbingStairsDFSMem(n: number): number { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + const mem = new Array(n + 1).fill(-1); + return dfs(n, mem); + } + ``` + +=== "Dart" + + ```dart title="climbing_stairs_dfs_mem.dart" + /* 메모이제이션을 이용한 탐색 */ + int dfs(int i, List mem) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) return i; + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if (mem[i] != -1) return mem[i]; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1, mem) + dfs(i - 2, mem); + // dp[i]를 기록한다 + mem[i] = count; + return count; + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + int climbingStairsDFSMem(int n) { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + List mem = List.filled(n + 1, -1); + return dfs(n, mem); + } + ``` + +=== "Rust" + + ```rust title="climbing_stairs_dfs_mem.rs" + /* 메모이제이션을 이용한 탐색 */ + fn dfs(i: usize, mem: &mut [i32]) -> i32 { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if i == 1 || i == 2 { + return i as i32; + } + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if mem[i] != -1 { + return mem[i]; + } + // dp[i] = dp[i-1] + dp[i-2] + let count = dfs(i - 1, mem) + dfs(i - 2, mem); + // dp[i]를 기록한다 + mem[i] = count; + count + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + fn climbing_stairs_dfs_mem(n: usize) -> i32 { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + let mut mem = vec![-1; n + 1]; + dfs(n, &mut mem) + } + ``` + +=== "C" + + ```c title="climbing_stairs_dfs_mem.c" + /* 메모이제이션을 이용한 탐색 */ + int dfs(int i, int *mem) { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) + return i; + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if (mem[i] != -1) + return mem[i]; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1, mem) + dfs(i - 2, mem); + // dp[i]를 기록한다 + mem[i] = count; + return count; + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + int climbingStairsDFSMem(int n) { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + int *mem = (int *)malloc((n + 1) * sizeof(int)); + for (int i = 0; i <= n; i++) { + mem[i] = -1; + } + int result = dfs(n, mem); + free(mem); + return result; + } + ``` + +=== "Kotlin" + + ```kotlin title="climbing_stairs_dfs_mem.kt" + /* 메모이제이션을 이용한 탐색 */ + fun dfs(i: Int, mem: IntArray): Int { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if (i == 1 || i == 2) return i + // dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + if (mem[i] != -1) return mem[i] + // dp[i] = dp[i-1] + dp[i-2] + val count = dfs(i - 1, mem) + dfs(i - 2, mem) + // dp[i]를 기록한다 + mem[i] = count + return count + } + + /* 계단 오르기: 메모이제이션을 이용한 탐색 */ + fun climbingStairsDFSMem(n: Int): Int { + // mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + val mem = IntArray(n + 1) + mem.fill(-1) + return dfs(n, mem) + } + ``` + +=== "Ruby" + + ```ruby title="climbing_stairs_dfs_mem.rb" + ### 메모이제이션을 이용한 탐색 ### + def dfs(i, mem) + # dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + return i if i == 1 || i == 2 + # dp[i]의 기록이 있으면 해당 값을 바로 반환한다 + return mem[i] if mem[i] != -1 + + # dp[i] = dp[i-1] + dp[i-2] + count = dfs(i - 1, mem) + dfs(i - 2, mem) + # dp[i]를 기록한다 + mem[i] = count + end + + ### 계단 오르기: 메모이제이션을 이용한 탐색 ### + def climbing_stairs_dfs_mem(n) + # mem[i]는 i번째 계단까지 오르는 전체 방법의 수를 기록하며, -1은 기록이 없음을 나타낸다 + mem = Array.new(n + 1, -1) + dfs(n, mem) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-4)과 같이 **메모이제이션을 적용하면 모든 중복 부분 문제를 한 번만 계산하므로 시간 복잡도가 $O(n)$으로 개선된다**. 이는 매우 큰 발전이다. + +![메모이제이션을 이용한 탐색의 재귀 트리](intro_to_dynamic_programming.assets/climbing_stairs_dfs_memo_tree.png){ class="animation-figure" } + +

그림 14-4   메모이제이션을 이용한 탐색의 재귀 트리

+ +## 14.1.3   방법 3: 동적 계획법 + +**메모이제이션을 이용한 탐색은 '위에서 아래로' 진행하는 방법**이다. 원래 문제(루트 노드)에서 시작하여 큰 부분 문제를 작은 부분 문제로 재귀적으로 나누고, 해를 이미 아는 가장 작은 부분 문제(리프 노드)에 도달한다. 이후 되돌아오면서 각 단계의 해를 모아 원래 문제의 해를 구성한다. + +반대로 **동적 계획법은 '아래에서 위로' 진행하는 방법**이다. 가장 작은 부분 문제의 해에서 시작하여 반복적으로 더 큰 부분 문제의 해를 구성하고, 마침내 원래 문제의 해를 얻는다. + +동적 계획법에는 되돌아오는 과정이 없으므로 재귀 없이 반복문만으로 구현할 수 있다. 아래 코드에서는 부분 문제의 해를 저장하는 배열 `dp`를 초기화한다. 이는 메모이제이션을 이용한 탐색의 배열 `mem`과 같은 기록 역할을 한다. + +=== "Python" + + ```python title="climbing_stairs_dp.py" + def climbing_stairs_dp(n: int) -> int: + """계단 오르기: 동적 계획법""" + if n == 1 or n == 2: + return n + # 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp = [0] * (n + 1) + # 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1], dp[2] = 1, 2 + # 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in range(3, n + 1): + dp[i] = dp[i - 1] + dp[i - 2] + return dp[n] + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dp.cpp" + /* 계단 오르기: 동적 계획법 */ + int climbingStairsDP(int n) { + if (n == 1 || n == 2) + return n; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + vector dp(n + 1); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1; + dp[2] = 2; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = dp[i - 1] + dp[i - 2]; + } + return dp[n]; + } + ``` + +=== "Java" + + ```java title="climbing_stairs_dp.java" + /* 계단 오르기: 동적 계획법 */ + int climbingStairsDP(int n) { + if (n == 1 || n == 2) + return n; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + int[] dp = new int[n + 1]; + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1; + dp[2] = 2; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = dp[i - 1] + dp[i - 2]; + } + return dp[n]; + } + ``` + +=== "C#" + + ```csharp title="climbing_stairs_dp.cs" + /* 계단 오르기: 동적 계획법 */ + int ClimbingStairsDP(int n) { + if (n == 1 || n == 2) + return n; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + int[] dp = new int[n + 1]; + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1; + dp[2] = 2; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = dp[i - 1] + dp[i - 2]; + } + return dp[n]; + } + ``` + +=== "Go" + + ```go title="climbing_stairs_dp.go" + /* 계단 오르기: 동적 계획법 */ + func climbingStairsDP(n int) int { + if n == 1 || n == 2 { + return n + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp := make([]int, n+1) + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1 + dp[2] = 2 + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i := 3; i <= n; i++ { + dp[i] = dp[i-1] + dp[i-2] + } + return dp[n] + } + ``` + +=== "Swift" + + ```swift title="climbing_stairs_dp.swift" + /* 계단 오르기: 동적 계획법 */ + func climbingStairsDP(n: Int) -> Int { + if n == 1 || n == 2 { + return n + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + var dp = Array(repeating: 0, count: n + 1) + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1 + dp[2] = 2 + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in 3 ... n { + dp[i] = dp[i - 1] + dp[i - 2] + } + return dp[n] + } + ``` + +=== "JS" + + ```javascript title="climbing_stairs_dp.js" + /* 계단 오르기: 동적 계획법 */ + function climbingStairsDP(n) { + if (n === 1 || n === 2) return n; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + const dp = new Array(n + 1).fill(-1); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1; + dp[2] = 2; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (let i = 3; i <= n; i++) { + dp[i] = dp[i - 1] + dp[i - 2]; + } + return dp[n]; + } + ``` + +=== "TS" + + ```typescript title="climbing_stairs_dp.ts" + /* 계단 오르기: 동적 계획법 */ + function climbingStairsDP(n: number): number { + if (n === 1 || n === 2) return n; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + const dp = new Array(n + 1).fill(-1); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1; + dp[2] = 2; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (let i = 3; i <= n; i++) { + dp[i] = dp[i - 1] + dp[i - 2]; + } + return dp[n]; + } + ``` + +=== "Dart" + + ```dart title="climbing_stairs_dp.dart" + /* 계단 오르기: 동적 계획법 */ + int climbingStairsDP(int n) { + if (n == 1 || n == 2) return n; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + List dp = List.filled(n + 1, 0); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1; + dp[2] = 2; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = dp[i - 1] + dp[i - 2]; + } + return dp[n]; + } + ``` + +=== "Rust" + + ```rust title="climbing_stairs_dp.rs" + /* 계단 오르기: 동적 계획법 */ + fn climbing_stairs_dp(n: usize) -> i32 { + // dp[1]과 dp[2]는 이미 알고 있으므로 해당 값을 반환한다 + if n == 1 || n == 2 { + return n as i32; + } + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + let mut dp = vec![-1; n + 1]; + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1; + dp[2] = 2; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i in 3..=n { + dp[i] = dp[i - 1] + dp[i - 2]; + } + dp[n] + } + ``` + +=== "C" + + ```c title="climbing_stairs_dp.c" + /* 계단 오르기: 동적 계획법 */ + int climbingStairsDP(int n) { + if (n == 1 || n == 2) + return n; + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + int *dp = (int *)malloc((n + 1) * sizeof(int)); + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1; + dp[2] = 2; + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (int i = 3; i <= n; i++) { + dp[i] = dp[i - 1] + dp[i - 2]; + } + int result = dp[n]; + free(dp); + return result; + } + ``` + +=== "Kotlin" + + ```kotlin title="climbing_stairs_dp.kt" + /* 계단 오르기: 동적 계획법 */ + fun climbingStairsDP(n: Int): Int { + if (n == 1 || n == 2) return n + // 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + val dp = IntArray(n + 1) + // 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1] = 1 + dp[2] = 2 + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for (i in 3..n) { + dp[i] = dp[i - 1] + dp[i - 2] + } + return dp[n] + } + ``` + +=== "Ruby" + + ```ruby title="climbing_stairs_dp.rb" + ### 계단 오르기: 동적 계획법 ### + def climbing_stairs_dp(n) + return n if n == 1 || n == 2 + + # 부분 문제의 해를 저장할 dp 테이블을 초기화한다 + dp = Array.new(n + 1, 0) + # 초기 상태: 가장 작은 부분 문제의 해를 미리 설정한다 + dp[1], dp[2] = 1, 2 + # 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + (3...(n + 1)).each { |i| dp[i] = dp[i - 1] + dp[i - 2] } + + dp[n] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-5)은 위 코드의 실행 과정을 보여 준다. + +![계단 오르기의 동적 계획법 과정](intro_to_dynamic_programming.assets/climbing_stairs_dp.png){ class="animation-figure" } + +

그림 14-5   계단 오르기의 동적 계획법 과정

+ +백트래킹과 마찬가지로 동적 계획법도 '상태'로 문제 해결의 특정 단계를 나타낸다. 각 상태는 부분 문제 하나와 그에 해당하는 국소 최적해에 대응한다. 예를 들어 계단 오르기 문제의 상태는 현재 위치한 계단의 칸 번호 $i$로 정의한다. + +위 내용을 바탕으로 동적 계획법의 주요 용어를 정리할 수 있다. + +- 배열 `dp`를 dp 테이블이라고 하며, $dp[i]$는 상태 $i$에 대응하는 부분 문제의 해다. +- 가장 작은 부분 문제에 해당하는 상태(계단의 $1$, $2$ 번째 칸)를 초기 상태라고 한다. +- 점화식 $dp[i] = dp[i-1] + dp[i-2]$를 상태 전이 식이라고 한다. + +## 14.1.4   공간 최적화 + +주의 깊게 읽었다면 **$dp[i]$는 $dp[i-1]$과 $dp[i-2]$에만 의존하므로 배열 `dp`에 모든 부분 문제의 해를 저장할 필요가 없음**을 알아차렸을 것이다. 변수 두 개를 차례로 갱신하며 진행하면 된다. 코드는 다음과 같다. + +=== "Python" + + ```python title="climbing_stairs_dp.py" + def climbing_stairs_dp_comp(n: int) -> int: + """계단 오르기: 공간을 최적화한 동적 계획법""" + if n == 1 or n == 2: + return n + a, b = 1, 2 + for _ in range(3, n + 1): + a, b = b, a + b + return b + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dp.cpp" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + int climbingStairsDPComp(int n) { + if (n == 1 || n == 2) + return n; + int a = 1, b = 2; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "Java" + + ```java title="climbing_stairs_dp.java" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + int climbingStairsDPComp(int n) { + if (n == 1 || n == 2) + return n; + int a = 1, b = 2; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "C#" + + ```csharp title="climbing_stairs_dp.cs" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + int ClimbingStairsDPComp(int n) { + if (n == 1 || n == 2) + return n; + int a = 1, b = 2; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "Go" + + ```go title="climbing_stairs_dp.go" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + func climbingStairsDPComp(n int) int { + if n == 1 || n == 2 { + return n + } + a, b := 1, 2 + // 상태 전이: 작은 부분 문제부터 점차 큰 부분 문제를 푼다 + for i := 3; i <= n; i++ { + a, b = b, a+b + } + return b + } + ``` + +=== "Swift" + + ```swift title="climbing_stairs_dp.swift" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + func climbingStairsDPComp(n: Int) -> Int { + if n == 1 || n == 2 { + return n + } + var a = 1 + var b = 2 + for _ in 3 ... n { + (a, b) = (b, a + b) + } + return b + } + ``` + +=== "JS" + + ```javascript title="climbing_stairs_dp.js" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + function climbingStairsDPComp(n) { + if (n === 1 || n === 2) return n; + let a = 1, + b = 2; + for (let i = 3; i <= n; i++) { + const tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "TS" + + ```typescript title="climbing_stairs_dp.ts" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + function climbingStairsDPComp(n: number): number { + if (n === 1 || n === 2) return n; + let a = 1, + b = 2; + for (let i = 3; i <= n; i++) { + const tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "Dart" + + ```dart title="climbing_stairs_dp.dart" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + int climbingStairsDPComp(int n) { + if (n == 1 || n == 2) return n; + int a = 1, b = 2; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "Rust" + + ```rust title="climbing_stairs_dp.rs" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + fn climbing_stairs_dp_comp(n: usize) -> i32 { + if n == 1 || n == 2 { + return n as i32; + } + let (mut a, mut b) = (1, 2); + for _ in 3..=n { + let tmp = b; + b = a + b; + a = tmp; + } + b + } + ``` + +=== "C" + + ```c title="climbing_stairs_dp.c" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + int climbingStairsDPComp(int n) { + if (n == 1 || n == 2) + return n; + int a = 1, b = 2; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "Kotlin" + + ```kotlin title="climbing_stairs_dp.kt" + /* 계단 오르기: 공간을 최적화한 동적 계획법 */ + fun climbingStairsDPComp(n: Int): Int { + if (n == 1 || n == 2) return n + var a = 1 + var b = 2 + for (i in 3..n) { + val temp = b + b += a + a = temp + } + return b + } + ``` + +=== "Ruby" + + ```ruby title="climbing_stairs_dp.rb" + ### 계단 오르기: 공간을 최적화한 동적 계획법 ### + def climbing_stairs_dp_comp(n) + return n if n == 1 || n == 2 + + a, b = 1, 2 + (3...(n + 1)).each { a, b = b, a + b } + + b + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +위 코드에서는 배열 `dp`의 공간을 없앴으므로 공간 복잡도가 $O(n)$에서 $O(1)$로 줄어든다. + +동적 계획법의 현재 상태는 앞선 몇 개의 상태에만 의존하는 경우가 많다. 이때 필요한 상태만 남겨 '차원을 줄이면' 메모리를 절약할 수 있다. **이 공간 최적화 기법을 '롤링 변수' 또는 '롤링 배열'이라고 한다**. diff --git a/ko/docs/chapter_dynamic_programming/knapsack_problem.md b/ko/docs/chapter_dynamic_programming/knapsack_problem.md new file mode 100644 index 000000000..7efd91af5 --- /dev/null +++ b/ko/docs/chapter_dynamic_programming/knapsack_problem.md @@ -0,0 +1,1445 @@ +--- +comments: true +--- + +# 14.4   0-1 배낭 문제 + +배낭 문제는 동적 계획법 입문에 매우 좋은 예제이며 가장 흔한 문제 유형이다. 0-1 배낭, 무한 배낭, 개수 제한 배낭 등 여러 변형이 있다. + +이 절에서는 가장 흔한 0-1 배낭 문제부터 해결한다. + +!!! question "문제" + + 물건 $n$ 개와 용량이 $cap$인 배낭이 주어진다. $i$ 번째 물건의 무게는 $wgt[i-1]$, 가치는 $val[i-1]$이다. 각 물건을 한 번만 선택할 수 있을 때, 배낭 용량 제한 안에 담을 수 있는 물건의 최대 가치를 구하라. + +아래 그림(14-17)에서 물건 번호 $i$는 $1$부터, 배열 인덱스는 $0$부터 시작하므로 물건 $i$의 무게와 가치는 각각 $wgt[i-1]$, $val[i-1]$이다. + +![0-1 배낭 문제 예시 데이터](knapsack_problem.assets/knapsack_example.png){ class="animation-figure" } + +

그림 14-17   0-1 배낭 문제 예시 데이터

+ +0-1 배낭 문제는 $n$ 차례의 결정으로 이루어진 과정으로 볼 수 있다. 각 물건에는 넣지 않기와 넣기라는 두 결정이 있으므로 의사 결정 트리 모형을 만족한다. + +목표가 '배낭 용량 제한 안에 담을 수 있는 최대 가치'이므로 동적 계획법 문제일 가능성이 높다. + +**1단계: 매 차례의 결정을 생각하고 상태를 정의하여 $dp$ 테이블을 얻는다** + +물건을 넣지 않으면 배낭 용량은 그대로이고, 넣으면 용량이 줄어든다. 따라서 현재 물건 번호 $i$와 배낭 용량 $c$로 상태를 정의하고 $[i, c]$라고 한다. + +상태 $[i, c]$의 부분 문제는 **앞의 $i$ 개 물건을 용량 $c$인 배낭에 담았을 때의 최대 가치**이며, 이를 $dp[i, c]$라고 한다. + +구할 값은 $dp[n, cap]$이므로 $(n+1) \times (cap+1)$ 크기의 2차원 $dp$ 테이블이 필요하다. + +**2단계: 최적 부분 구조를 찾아 상태 전이 식을 도출한다** + +물건 $i$에 대한 결정을 내리면 앞의 $i-1$ 개 물건을 결정하는 부분 문제가 남는다. 다음 두 경우로 나뉜다. + +- **물건 $i$를 넣지 않음**: 용량은 그대로이며 상태는 $[i-1, c]$로 바뀐다. +- **물건 $i$를 넣음**: 용량이 $wgt[i-1]$ 줄고 가치가 $val[i-1]$ 늘며, 상태는 $[i-1, c-wgt[i-1]]$로 바뀐다. + +이 분석에서 최적 부분 구조가 드러난다. **최대 가치 $dp[i, c]$는 물건 $i$를 넣지 않는 경우와 물건 $i$를 넣는 경우 중 가치가 더 큰 쪽이다**. 상태 전이 식은 다음과 같다. + +$$ +dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1]) +$$ + +현재 물건의 무게 $wgt[i - 1]$가 남은 용량 $c$를 넘으면 넣지 않는 선택만 가능하다. + +**3단계: 경계 조건과 상태 전이 순서를 정한다** + +물건이 없거나 배낭 용량이 $0$이면 최대 가치는 $0$이다. 즉, 첫 열 $dp[i, 0]$과 첫 행 $dp[0, c]$는 모두 $0$이다. + +현재 상태 $[i, c]$는 바로 위의 $[i-1, c]$와 왼쪽 위의 $[i-1, c-wgt[i-1]]$에서 전이되므로, 이중 반복문으로 전체 $dp$ 테이블을 정방향으로 순회하면 된다. + +이 분석을 바탕으로 완전 탐색, 메모이제이션을 이용한 탐색, 동적 계획법 순서로 구현한다. + +### 1.   방법 1: 완전 탐색 + +탐색 코드에는 다음 요소가 포함된다. + +- **재귀 매개변수**: 상태 $[i, c]$. +- **반환값**: 부분 문제의 해 $dp[i, c]$. +- **종료 조건**: 물건 번호가 범위를 벗어나 $i = 0$이 되거나 남은 용량이 $0$이면 재귀를 종료하고 가치 $0$을 반환한다. +- **가지치기**: 현재 물건의 무게가 남은 배낭 용량보다 크면 넣지 않는 선택만 할 수 있다. + +=== "Python" + + ```python title="knapsack.py" + def knapsack_dfs(wgt: list[int], val: list[int], i: int, c: int) -> int: + """0-1 배낭: 완전 탐색""" + # 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if i == 0 or c == 0: + return 0 + # 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if wgt[i - 1] > c: + return knapsack_dfs(wgt, val, i - 1, c) + # 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + no = knapsack_dfs(wgt, val, i - 1, c) + yes = knapsack_dfs(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1] + # 두 경우 중 더 큰 가치를 반환한다 + return max(no, yes) + ``` + +=== "C++" + + ```cpp title="knapsack.cpp" + /* 0-1 배낭: 완전 탐색 */ + int knapsackDFS(vector &wgt, vector &val, int i, int c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFS(wgt, val, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = knapsackDFS(wgt, val, i - 1, c); + int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 반환한다 + return max(no, yes); + } + ``` + +=== "Java" + + ```java title="knapsack.java" + /* 0-1 배낭: 완전 탐색 */ + int knapsackDFS(int[] wgt, int[] val, int i, int c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFS(wgt, val, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = knapsackDFS(wgt, val, i - 1, c); + int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 반환한다 + return Math.max(no, yes); + } + ``` + +=== "C#" + + ```csharp title="knapsack.cs" + /* 0-1 배낭: 완전 탐색 */ + int KnapsackDFS(int[] weight, int[] val, int i, int c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (weight[i - 1] > c) { + return KnapsackDFS(weight, val, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = KnapsackDFS(weight, val, i - 1, c); + int yes = KnapsackDFS(weight, val, i - 1, c - weight[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 반환한다 + return Math.Max(no, yes); + } + ``` + +=== "Go" + + ```go title="knapsack.go" + /* 0-1 배낭: 완전 탐색 */ + func knapsackDFS(wgt, val []int, i, c int) int { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if i == 0 || c == 0 { + return 0 + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if wgt[i-1] > c { + return knapsackDFS(wgt, val, i-1, c) + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + no := knapsackDFS(wgt, val, i-1, c) + yes := knapsackDFS(wgt, val, i-1, c-wgt[i-1]) + val[i-1] + // 두 경우 중 더 큰 가치를 반환한다 + return int(math.Max(float64(no), float64(yes))) + } + ``` + +=== "Swift" + + ```swift title="knapsack.swift" + /* 0-1 배낭: 완전 탐색 */ + func knapsackDFS(wgt: [Int], val: [Int], i: Int, c: Int) -> Int { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if i == 0 || c == 0 { + return 0 + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if wgt[i - 1] > c { + return knapsackDFS(wgt: wgt, val: val, i: i - 1, c: c) + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + let no = knapsackDFS(wgt: wgt, val: val, i: i - 1, c: c) + let yes = knapsackDFS(wgt: wgt, val: val, i: i - 1, c: c - wgt[i - 1]) + val[i - 1] + // 두 경우 중 더 큰 가치를 반환한다 + return max(no, yes) + } + ``` + +=== "JS" + + ```javascript title="knapsack.js" + /* 0-1 배낭: 완전 탐색 */ + function knapsackDFS(wgt, val, i, c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i === 0 || c === 0) { + return 0; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFS(wgt, val, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + const no = knapsackDFS(wgt, val, i - 1, c); + const yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 반환한다 + return Math.max(no, yes); + } + ``` + +=== "TS" + + ```typescript title="knapsack.ts" + /* 0-1 배낭: 완전 탐색 */ + function knapsackDFS( + wgt: Array, + val: Array, + i: number, + c: number + ): number { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i === 0 || c === 0) { + return 0; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFS(wgt, val, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + const no = knapsackDFS(wgt, val, i - 1, c); + const yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 반환한다 + return Math.max(no, yes); + } + ``` + +=== "Dart" + + ```dart title="knapsack.dart" + /* 0-1 배낭: 완전 탐색 */ + int knapsackDFS(List wgt, List val, int i, int c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFS(wgt, val, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = knapsackDFS(wgt, val, i - 1, c); + int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 반환한다 + return max(no, yes); + } + ``` + +=== "Rust" + + ```rust title="knapsack.rs" + /* 0-1 배낭: 완전 탐색 */ + fn knapsack_dfs(wgt: &[i32], val: &[i32], i: usize, c: usize) -> i32 { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if i == 0 || c == 0 { + return 0; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if wgt[i - 1] > c as i32 { + return knapsack_dfs(wgt, val, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + let no = knapsack_dfs(wgt, val, i - 1, c); + let yes = knapsack_dfs(wgt, val, i - 1, c - wgt[i - 1] as usize) + val[i - 1]; + // 두 경우 중 더 큰 가치를 반환한다 + std::cmp::max(no, yes) + } + ``` + +=== "C" + + ```c title="knapsack.c" + /* 0-1 배낭: 완전 탐색 */ + int knapsackDFS(int wgt[], int val[], int i, int c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFS(wgt, val, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = knapsackDFS(wgt, val, i - 1, c); + int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 반환한다 + return myMax(no, yes); + } + ``` + +=== "Kotlin" + + ```kotlin title="knapsack.kt" + /* 0-1 배낭: 완전 탐색 */ + fun knapsackDFS( + wgt: IntArray, + _val: IntArray, + i: Int, + c: Int + ): Int { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0 + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFS(wgt, _val, i - 1, c) + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + val no = knapsackDFS(wgt, _val, i - 1, c) + val yes = knapsackDFS(wgt, _val, i - 1, c - wgt[i - 1]) + _val[i - 1] + // 두 경우 중 더 큰 가치를 반환한다 + return max(no, yes) + } + ``` + +=== "Ruby" + + ```ruby title="knapsack.rb" + ### 0-1 배낭: 완전 탐색 ### + def knapsack_dfs(wgt, val, i, c) + # 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + return 0 if i == 0 || c == 0 + # 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + return knapsack_dfs(wgt, val, i - 1, c) if wgt[i - 1] > c + # 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + no = knapsack_dfs(wgt, val, i - 1, c) + yes = knapsack_dfs(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1] + # 두 경우 중 더 큰 가치를 반환한다 + [no, yes].max + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-18)과 같이 물건마다 선택하지 않기와 선택하기라는 두 탐색 가지가 생기므로 시간 복잡도는 $O(2^n)$이다. + +재귀 트리에는 $dp[1, 10]$ 같은 중복 부분 문제가 있음을 쉽게 알 수 있다. 물건이 많고 용량이 크며, 특히 무게가 같은 물건이 많을수록 중복 부분 문제의 수가 크게 늘어난다. + +![0-1 배낭 문제의 완전 탐색 재귀 트리](knapsack_problem.assets/knapsack_dfs.png){ class="animation-figure" } + +

그림 14-18   0-1 배낭 문제의 완전 탐색 재귀 트리

+ +### 2.   방법 2: 메모이제이션을 이용한 탐색 + +중복 부분 문제를 한 번만 계산하도록 메모이제이션 리스트 `mem`에 부분 문제의 해를 저장한다. `mem[i][c]`는 $dp[i, c]$에 대응한다. + +메모이제이션을 도입하면 **시간 복잡도는 부분 문제의 수에 의해 결정되므로** $O(n \times cap)$이다. 구현 코드는 다음과 같다. + +=== "Python" + + ```python title="knapsack.py" + def knapsack_dfs_mem( + wgt: list[int], val: list[int], mem: list[list[int]], i: int, c: int + ) -> int: + """0-1 배낭: 메모이제이션을 이용한 탐색""" + # 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if i == 0 or c == 0: + return 0 + # 기록이 이미 있으면 바로 반환한다 + if mem[i][c] != -1: + return mem[i][c] + # 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if wgt[i - 1] > c: + return knapsack_dfs_mem(wgt, val, mem, i - 1, c) + # 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + no = knapsack_dfs_mem(wgt, val, mem, i - 1, c) + yes = knapsack_dfs_mem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1] + # 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = max(no, yes) + return mem[i][c] + ``` + +=== "C++" + + ```cpp title="knapsack.cpp" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + int knapsackDFSMem(vector &wgt, vector &val, vector> &mem, int i, int c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][c] != -1) { + return mem[i][c]; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFSMem(wgt, val, mem, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = knapsackDFSMem(wgt, val, mem, i - 1, c); + int yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = max(no, yes); + return mem[i][c]; + } + ``` + +=== "Java" + + ```java title="knapsack.java" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + int knapsackDFSMem(int[] wgt, int[] val, int[][] mem, int i, int c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][c] != -1) { + return mem[i][c]; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFSMem(wgt, val, mem, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = knapsackDFSMem(wgt, val, mem, i - 1, c); + int yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = Math.max(no, yes); + return mem[i][c]; + } + ``` + +=== "C#" + + ```csharp title="knapsack.cs" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + int KnapsackDFSMem(int[] weight, int[] val, int[][] mem, int i, int c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][c] != -1) { + return mem[i][c]; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (weight[i - 1] > c) { + return KnapsackDFSMem(weight, val, mem, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = KnapsackDFSMem(weight, val, mem, i - 1, c); + int yes = KnapsackDFSMem(weight, val, mem, i - 1, c - weight[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = Math.Max(no, yes); + return mem[i][c]; + } + ``` + +=== "Go" + + ```go title="knapsack.go" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + func knapsackDFSMem(wgt, val []int, mem [][]int, i, c int) int { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if i == 0 || c == 0 { + return 0 + } + // 기록이 이미 있으면 바로 반환한다 + if mem[i][c] != -1 { + return mem[i][c] + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if wgt[i-1] > c { + return knapsackDFSMem(wgt, val, mem, i-1, c) + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + no := knapsackDFSMem(wgt, val, mem, i-1, c) + yes := knapsackDFSMem(wgt, val, mem, i-1, c-wgt[i-1]) + val[i-1] + // 두 경우 중 더 큰 가치를 반환한다 + mem[i][c] = int(math.Max(float64(no), float64(yes))) + return mem[i][c] + } + ``` + +=== "Swift" + + ```swift title="knapsack.swift" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + func knapsackDFSMem(wgt: [Int], val: [Int], mem: inout [[Int]], i: Int, c: Int) -> Int { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if i == 0 || c == 0 { + return 0 + } + // 기록이 이미 있으면 바로 반환한다 + if mem[i][c] != -1 { + return mem[i][c] + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if wgt[i - 1] > c { + return knapsackDFSMem(wgt: wgt, val: val, mem: &mem, i: i - 1, c: c) + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + let no = knapsackDFSMem(wgt: wgt, val: val, mem: &mem, i: i - 1, c: c) + let yes = knapsackDFSMem(wgt: wgt, val: val, mem: &mem, i: i - 1, c: c - wgt[i - 1]) + val[i - 1] + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = max(no, yes) + return mem[i][c] + } + ``` + +=== "JS" + + ```javascript title="knapsack.js" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + function knapsackDFSMem(wgt, val, mem, i, c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i === 0 || c === 0) { + return 0; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][c] !== -1) { + return mem[i][c]; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFSMem(wgt, val, mem, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + const no = knapsackDFSMem(wgt, val, mem, i - 1, c); + const yes = + knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = Math.max(no, yes); + return mem[i][c]; + } + ``` + +=== "TS" + + ```typescript title="knapsack.ts" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + function knapsackDFSMem( + wgt: Array, + val: Array, + mem: Array>, + i: number, + c: number + ): number { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i === 0 || c === 0) { + return 0; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][c] !== -1) { + return mem[i][c]; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFSMem(wgt, val, mem, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + const no = knapsackDFSMem(wgt, val, mem, i - 1, c); + const yes = + knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = Math.max(no, yes); + return mem[i][c]; + } + ``` + +=== "Dart" + + ```dart title="knapsack.dart" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + int knapsackDFSMem( + List wgt, + List val, + List> mem, + int i, + int c, + ) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][c] != -1) { + return mem[i][c]; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFSMem(wgt, val, mem, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = knapsackDFSMem(wgt, val, mem, i - 1, c); + int yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = max(no, yes); + return mem[i][c]; + } + ``` + +=== "Rust" + + ```rust title="knapsack.rs" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + fn knapsack_dfs_mem(wgt: &[i32], val: &[i32], mem: &mut Vec>, i: usize, c: usize) -> i32 { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if i == 0 || c == 0 { + return 0; + } + // 기록이 이미 있으면 바로 반환한다 + if mem[i][c] != -1 { + return mem[i][c]; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if wgt[i - 1] > c as i32 { + return knapsack_dfs_mem(wgt, val, mem, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + let no = knapsack_dfs_mem(wgt, val, mem, i - 1, c); + let yes = knapsack_dfs_mem(wgt, val, mem, i - 1, c - wgt[i - 1] as usize) + val[i - 1]; + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = std::cmp::max(no, yes); + mem[i][c] + } + ``` + +=== "C" + + ```c title="knapsack.c" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + int knapsackDFSMem(int wgt[], int val[], int memCols, int **mem, int i, int c) { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0; + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][c] != -1) { + return mem[i][c]; + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFSMem(wgt, val, memCols, mem, i - 1, c); + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + int no = knapsackDFSMem(wgt, val, memCols, mem, i - 1, c); + int yes = knapsackDFSMem(wgt, val, memCols, mem, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = myMax(no, yes); + return mem[i][c]; + } + ``` + +=== "Kotlin" + + ```kotlin title="knapsack.kt" + /* 0-1 배낭: 메모이제이션을 이용한 탐색 */ + fun knapsackDFSMem( + wgt: IntArray, + _val: IntArray, + mem: Array, + i: Int, + c: Int + ): Int { + // 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + if (i == 0 || c == 0) { + return 0 + } + // 기록이 이미 있으면 바로 반환한다 + if (mem[i][c] != -1) { + return mem[i][c] + } + // 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + if (wgt[i - 1] > c) { + return knapsackDFSMem(wgt, _val, mem, i - 1, c) + } + // 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + val no = knapsackDFSMem(wgt, _val, mem, i - 1, c) + val yes = knapsackDFSMem(wgt, _val, mem, i - 1, c - wgt[i - 1]) + _val[i - 1] + // 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = max(no, yes) + return mem[i][c] + } + ``` + +=== "Ruby" + + ```ruby title="knapsack.rb" + ### 0-1 배낭: 메모이제이션을 이용한 탐색 ### + def knapsack_dfs_mem(wgt, val, mem, i, c) + # 모든 물건의 선택을 마쳤거나 배낭에 남은 용량이 없으면 가치 0을 반환한다 + return 0 if i == 0 || c == 0 + # 기록이 이미 있으면 바로 반환한다 + return mem[i][c] if mem[i][c] != -1 + # 배낭 용량을 초과하면 배낭에 넣지 않는 선택만 가능하다 + return knapsack_dfs_mem(wgt, val, mem, i - 1, c) if wgt[i - 1] > c + # 물건 i를 넣지 않는 경우와 넣는 경우의 최대 가치를 계산한다 + no = knapsack_dfs_mem(wgt, val, mem, i - 1, c) + yes = knapsack_dfs_mem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1] + # 두 경우 중 더 큰 가치를 기록하고 반환한다 + mem[i][c] = [no, yes].max + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-19)은 메모이제이션을 이용한 탐색에서 잘라 낸 가지들을 보여 준다. + +![0-1 배낭 문제의 메모이제이션 탐색 재귀 트리](knapsack_problem.assets/knapsack_dfs_mem.png){ class="animation-figure" } + +

그림 14-19   0-1 배낭 문제의 메모이제이션 탐색 재귀 트리

+ +### 3.   방법 3: 동적 계획법 + +동적 계획법은 본질적으로 상태 전이를 통해 $dp$ 테이블을 채우는 과정이다. 코드는 다음과 같다. + +=== "Python" + + ```python title="knapsack.py" + def knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int: + """0-1 배낭: 동적 계획법""" + n = len(wgt) + # dp 테이블을 초기화한다 + dp = [[0] * (cap + 1) for _ in range(n + 1)] + # 상태 전이 + for i in range(1, n + 1): + for c in range(1, cap + 1): + if wgt[i - 1] > c: + # 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c] + else: + # 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]) + return dp[n][cap] + ``` + +=== "C++" + + ```cpp title="knapsack.cpp" + /* 0-1 배낭: 동적 계획법 */ + int knapsackDP(vector &wgt, vector &val, int cap) { + int n = wgt.size(); + // dp 테이블을 초기화한다 + vector> dp(n + 1, vector(cap + 1, 0)); + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[n][cap]; + } + ``` + +=== "Java" + + ```java title="knapsack.java" + /* 0-1 배낭: 동적 계획법 */ + int knapsackDP(int[] wgt, int[] val, int cap) { + int n = wgt.length; + // dp 테이블을 초기화한다 + int[][] dp = new int[n + 1][cap + 1]; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = Math.max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[n][cap]; + } + ``` + +=== "C#" + + ```csharp title="knapsack.cs" + /* 0-1 배낭: 동적 계획법 */ + int KnapsackDP(int[] weight, int[] val, int cap) { + int n = weight.Length; + // dp 테이블을 초기화한다 + int[,] dp = new int[n + 1, cap + 1]; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (weight[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i, c] = dp[i - 1, c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i, c] = Math.Max(dp[i - 1, c - weight[i - 1]] + val[i - 1], dp[i - 1, c]); + } + } + } + return dp[n, cap]; + } + ``` + +=== "Go" + + ```go title="knapsack.go" + /* 0-1 배낭: 동적 계획법 */ + func knapsackDP(wgt, val []int, cap int) int { + n := len(wgt) + // dp 테이블을 초기화한다 + dp := make([][]int, n+1) + for i := 0; i <= n; i++ { + dp[i] = make([]int, cap+1) + } + // 상태 전이 + for i := 1; i <= n; i++ { + for c := 1; c <= cap; c++ { + if wgt[i-1] > c { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i-1][c] + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = int(math.Max(float64(dp[i-1][c]), float64(dp[i-1][c-wgt[i-1]]+val[i-1]))) + } + } + } + return dp[n][cap] + } + ``` + +=== "Swift" + + ```swift title="knapsack.swift" + /* 0-1 배낭: 동적 계획법 */ + func knapsackDP(wgt: [Int], val: [Int], cap: Int) -> Int { + let n = wgt.count + // dp 테이블을 초기화한다 + var dp = Array(repeating: Array(repeating: 0, count: cap + 1), count: n + 1) + // 상태 전이 + for i in 1 ... n { + for c in 1 ... cap { + if wgt[i - 1] > c { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c] + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]) + } + } + } + return dp[n][cap] + } + ``` + +=== "JS" + + ```javascript title="knapsack.js" + /* 0-1 배낭: 동적 계획법 */ + function knapsackDP(wgt, val, cap) { + const n = wgt.length; + // dp 테이블을 초기화한다 + const dp = Array(n + 1) + .fill(0) + .map(() => Array(cap + 1).fill(0)); + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = Math.max( + dp[i - 1][c], + dp[i - 1][c - wgt[i - 1]] + val[i - 1] + ); + } + } + } + return dp[n][cap]; + } + ``` + +=== "TS" + + ```typescript title="knapsack.ts" + /* 0-1 배낭: 동적 계획법 */ + function knapsackDP( + wgt: Array, + val: Array, + cap: number + ): number { + const n = wgt.length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: n + 1 }, () => + Array.from({ length: cap + 1 }, () => 0) + ); + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = Math.max( + dp[i - 1][c], + dp[i - 1][c - wgt[i - 1]] + val[i - 1] + ); + } + } + } + return dp[n][cap]; + } + ``` + +=== "Dart" + + ```dart title="knapsack.dart" + /* 0-1 배낭: 동적 계획법 */ + int knapsackDP(List wgt, List val, int cap) { + int n = wgt.length; + // dp 테이블을 초기화한다 + List> dp = List.generate(n + 1, (index) => List.filled(cap + 1, 0)); + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[n][cap]; + } + ``` + +=== "Rust" + + ```rust title="knapsack.rs" + /* 0-1 배낭: 동적 계획법 */ + fn knapsack_dp(wgt: &[i32], val: &[i32], cap: usize) -> i32 { + let n = wgt.len(); + // dp 테이블을 초기화한다 + let mut dp = vec![vec![0; cap + 1]; n + 1]; + // 상태 전이 + for i in 1..=n { + for c in 1..=cap { + if wgt[i - 1] > c as i32 { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = std::cmp::max( + dp[i - 1][c], + dp[i - 1][c - wgt[i - 1] as usize] + val[i - 1], + ); + } + } + } + dp[n][cap] + } + ``` + +=== "C" + + ```c title="knapsack.c" + /* 0-1 배낭: 동적 계획법 */ + int knapsackDP(int wgt[], int val[], int cap, int wgtSize) { + int n = wgtSize; + // dp 테이블을 초기화한다 + int **dp = malloc((n + 1) * sizeof(int *)); + for (int i = 0; i <= n; i++) { + dp[i] = calloc(cap + 1, sizeof(int)); + } + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = myMax(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]); + } + } + } + int res = dp[n][cap]; + // 메모리 해제 + for (int i = 0; i <= n; i++) { + free(dp[i]); + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="knapsack.kt" + /* 0-1 배낭: 동적 계획법 */ + fun knapsackDP(wgt: IntArray, _val: IntArray, cap: Int): Int { + val n = wgt.size + // dp 테이블을 초기화한다 + val dp = Array(n + 1) { IntArray(cap + 1) } + // 상태 전이 + for (i in 1..n) { + for (c in 1..cap) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c] + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + _val[i - 1]) + } + } + } + return dp[n][cap] + } + ``` + +=== "Ruby" + + ```ruby title="knapsack.rb" + ### 0-1 배낭: 동적 계획법 ### + def knapsack_dp(wgt, val, cap) + n = wgt.length + # dp 테이블을 초기화한다 + dp = Array.new(n + 1) { Array.new(cap + 1, 0) } + # 상태 전이 + for i in 1...(n + 1) + for c in 1...(cap + 1) + if wgt[i - 1] > c + # 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c] + else + # 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = [dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]].max + end + end + end + dp[n][cap] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-20)과 같이 시간 복잡도와 공간 복잡도는 모두 배열 `dp`의 크기인 $O(n \times cap)$이다. + +=== "<1>" + ![0-1 배낭 문제의 동적 계획법 과정](knapsack_problem.assets/knapsack_dp_step1.png){ class="animation-figure" } + +=== "<2>" + ![0-1 배낭 동적 계획법 2단계](knapsack_problem.assets/knapsack_dp_step2.png){ class="animation-figure" } + +=== "<3>" + ![0-1 배낭 동적 계획법 3단계](knapsack_problem.assets/knapsack_dp_step3.png){ class="animation-figure" } + +=== "<4>" + ![0-1 배낭 동적 계획법 4단계](knapsack_problem.assets/knapsack_dp_step4.png){ class="animation-figure" } + +=== "<5>" + ![0-1 배낭 동적 계획법 5단계](knapsack_problem.assets/knapsack_dp_step5.png){ class="animation-figure" } + +=== "<6>" + ![0-1 배낭 동적 계획법 6단계](knapsack_problem.assets/knapsack_dp_step6.png){ class="animation-figure" } + +=== "<7>" + ![0-1 배낭 동적 계획법 7단계](knapsack_problem.assets/knapsack_dp_step7.png){ class="animation-figure" } + +=== "<8>" + ![0-1 배낭 동적 계획법 8단계](knapsack_problem.assets/knapsack_dp_step8.png){ class="animation-figure" } + +=== "<9>" + ![0-1 배낭 동적 계획법 9단계](knapsack_problem.assets/knapsack_dp_step9.png){ class="animation-figure" } + +=== "<10>" + ![0-1 배낭 동적 계획법 10단계](knapsack_problem.assets/knapsack_dp_step10.png){ class="animation-figure" } + +=== "<11>" + ![0-1 배낭 동적 계획법 11단계](knapsack_problem.assets/knapsack_dp_step11.png){ class="animation-figure" } + +=== "<12>" + ![0-1 배낭 동적 계획법 12단계](knapsack_problem.assets/knapsack_dp_step12.png){ class="animation-figure" } + +=== "<13>" + ![0-1 배낭 동적 계획법 13단계](knapsack_problem.assets/knapsack_dp_step13.png){ class="animation-figure" } + +=== "<14>" + ![0-1 배낭 동적 계획법 14단계](knapsack_problem.assets/knapsack_dp_step14.png){ class="animation-figure" } + +

그림 14-20   0-1 배낭 문제의 동적 계획법 과정

+ +### 4.   공간 최적화 + +각 상태는 바로 윗행의 상태에만 의존하므로 두 배열을 번갈아 갱신하면 공간 복잡도를 $O(n \times cap)$에서 $O(cap)$으로 줄일 수 있다. + +배열 하나만으로도 가능할까? 각 상태는 바로 위 또는 왼쪽 위 칸에서 전이된다. 배열 하나를 쓴다면 $i$ 번째 행의 순회를 시작할 때 그 배열에는 아직 $i-1$ 번째 행의 상태가 저장되어 있다. + +- 정방향으로 순회하면 $dp[i, j]$에 도달했을 때 왼쪽 위 $dp[i-1, 1]$ ~ $dp[i-1, j-1]$의 값이 이미 덮어써졌을 수 있어 올바른 상태 전이를 할 수 없다. +- 역방향으로 순회하면 값이 덮어써지는 문제가 없어 상태 전이를 올바르게 수행할 수 있다. + +아래 그림(14-21)은 배열 하나로 $i = 1$ 행에서 $i = 2$ 행으로 바꾸는 과정이다. 정방향과 역방향 순회의 차이를 생각해 보자. + +=== "<1>" + ![공간 최적화 후 0-1 배낭의 동적 계획법 과정](knapsack_problem.assets/knapsack_dp_comp_step1.png){ class="animation-figure" } + +=== "<2>" + ![공간 최적화된 0-1 배낭 2단계](knapsack_problem.assets/knapsack_dp_comp_step2.png){ class="animation-figure" } + +=== "<3>" + ![공간 최적화된 0-1 배낭 3단계](knapsack_problem.assets/knapsack_dp_comp_step3.png){ class="animation-figure" } + +=== "<4>" + ![공간 최적화된 0-1 배낭 4단계](knapsack_problem.assets/knapsack_dp_comp_step4.png){ class="animation-figure" } + +=== "<5>" + ![공간 최적화된 0-1 배낭 5단계](knapsack_problem.assets/knapsack_dp_comp_step5.png){ class="animation-figure" } + +=== "<6>" + ![공간 최적화된 0-1 배낭 6단계](knapsack_problem.assets/knapsack_dp_comp_step6.png){ class="animation-figure" } + +

그림 14-21   공간 최적화 후 0-1 배낭의 동적 계획법 과정

+ +구현에서는 배열 `dp`의 첫 번째 차원 $i$를 없애고 안쪽 반복문을 역방향 순회로 바꾸기만 하면 된다. + +=== "Python" + + ```python title="knapsack.py" + def knapsack_dp_comp(wgt: list[int], val: list[int], cap: int) -> int: + """0-1 배낭: 공간을 최적화한 동적 계획법""" + n = len(wgt) + # dp 테이블을 초기화한다 + dp = [0] * (cap + 1) + # 상태 전이 + for i in range(1, n + 1): + # 역방향 순회 + for c in range(cap, 0, -1): + if wgt[i - 1] > c: + # 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c] + else: + # 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]) + return dp[cap] + ``` + +=== "C++" + + ```cpp title="knapsack.cpp" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + int knapsackDPComp(vector &wgt, vector &val, int cap) { + int n = wgt.size(); + // dp 테이블을 초기화한다 + vector dp(cap + 1, 0); + // 상태 전이 + for (int i = 1; i <= n; i++) { + // 역방향 순회 + for (int c = cap; c >= 1; c--) { + if (wgt[i - 1] <= c) { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "Java" + + ```java title="knapsack.java" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + int knapsackDPComp(int[] wgt, int[] val, int cap) { + int n = wgt.length; + // dp 테이블을 초기화한다 + int[] dp = new int[cap + 1]; + // 상태 전이 + for (int i = 1; i <= n; i++) { + // 역방향 순회 + for (int c = cap; c >= 1; c--) { + if (wgt[i - 1] <= c) { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "C#" + + ```csharp title="knapsack.cs" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + int KnapsackDPComp(int[] weight, int[] val, int cap) { + int n = weight.Length; + // dp 테이블을 초기화한다 + int[] dp = new int[cap + 1]; + // 상태 전이 + for (int i = 1; i <= n; i++) { + // 역방향 순회 + for (int c = cap; c > 0; c--) { + if (weight[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = Math.Max(dp[c], dp[c - weight[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "Go" + + ```go title="knapsack.go" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + func knapsackDPComp(wgt, val []int, cap int) int { + n := len(wgt) + // dp 테이블을 초기화한다 + dp := make([]int, cap+1) + // 상태 전이 + for i := 1; i <= n; i++ { + // 역방향 순회 + for c := cap; c >= 1; c-- { + if wgt[i-1] <= c { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = int(math.Max(float64(dp[c]), float64(dp[c-wgt[i-1]]+val[i-1]))) + } + } + } + return dp[cap] + } + ``` + +=== "Swift" + + ```swift title="knapsack.swift" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + func knapsackDPComp(wgt: [Int], val: [Int], cap: Int) -> Int { + let n = wgt.count + // dp 테이블을 초기화한다 + var dp = Array(repeating: 0, count: cap + 1) + // 상태 전이 + for i in 1 ... n { + // 역방향 순회 + for c in (1 ... cap).reversed() { + if wgt[i - 1] <= c { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]) + } + } + } + return dp[cap] + } + ``` + +=== "JS" + + ```javascript title="knapsack.js" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + function knapsackDPComp(wgt, val, cap) { + const n = wgt.length; + // dp 테이블을 초기화한다 + const dp = Array(cap + 1).fill(0); + // 상태 전이 + for (let i = 1; i <= n; i++) { + // 역방향 순회 + for (let c = cap; c >= 1; c--) { + if (wgt[i - 1] <= c) { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "TS" + + ```typescript title="knapsack.ts" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + function knapsackDPComp( + wgt: Array, + val: Array, + cap: number + ): number { + const n = wgt.length; + // dp 테이블을 초기화한다 + const dp = Array(cap + 1).fill(0); + // 상태 전이 + for (let i = 1; i <= n; i++) { + // 역방향 순회 + for (let c = cap; c >= 1; c--) { + if (wgt[i - 1] <= c) { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "Dart" + + ```dart title="knapsack.dart" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + int knapsackDPComp(List wgt, List val, int cap) { + int n = wgt.length; + // dp 테이블을 초기화한다 + List dp = List.filled(cap + 1, 0); + // 상태 전이 + for (int i = 1; i <= n; i++) { + // 역방향 순회 + for (int c = cap; c >= 1; c--) { + if (wgt[i - 1] <= c) { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "Rust" + + ```rust title="knapsack.rs" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + fn knapsack_dp_comp(wgt: &[i32], val: &[i32], cap: usize) -> i32 { + let n = wgt.len(); + // dp 테이블을 초기화한다 + let mut dp = vec![0; cap + 1]; + // 상태 전이 + for i in 1..=n { + // 역방향 순회 + for c in (1..=cap).rev() { + if wgt[i - 1] <= c as i32 { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = std::cmp::max(dp[c], dp[c - wgt[i - 1] as usize] + val[i - 1]); + } + } + } + dp[cap] + } + ``` + +=== "C" + + ```c title="knapsack.c" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + int knapsackDPComp(int wgt[], int val[], int cap, int wgtSize) { + int n = wgtSize; + // dp 테이블을 초기화한다 + int *dp = calloc(cap + 1, sizeof(int)); + // 상태 전이 + for (int i = 1; i <= n; i++) { + // 역방향 순회 + for (int c = cap; c >= 1; c--) { + if (wgt[i - 1] <= c) { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = myMax(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + int res = dp[cap]; + // 메모리 해제 + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="knapsack.kt" + /* 0-1 배낭: 공간을 최적화한 동적 계획법 */ + fun knapsackDPComp(wgt: IntArray, _val: IntArray, cap: Int): Int { + val n = wgt.size + // dp 테이블을 초기화한다 + val dp = IntArray(cap + 1) + // 상태 전이 + for (i in 1..n) { + // 역방향 순회 + for (c in cap downTo 1) { + if (wgt[i - 1] <= c) { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + _val[i - 1]) + } + } + } + return dp[cap] + } + ``` + +=== "Ruby" + + ```ruby title="knapsack.rb" + ### 0-1 배낭: 공간을 최적화한 동적 계획법 ### + def knapsack_dp_comp(wgt, val, cap) + n = wgt.length + # dp 테이블을 초기화한다 + dp = Array.new(cap + 1, 0) + # 상태 전이 + for i in 1...(n + 1) + # 역방향 순회 + for c in cap.downto(1) + if wgt[i - 1] > c + # 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c] + else + # 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = [dp[c], dp[c - wgt[i - 1]] + val[i - 1]].max + end + end + end + dp[cap] + end + ``` + +??? pythontutor "코드 시각화" + +
+ diff --git a/ko/docs/chapter_dynamic_programming/summary.md b/ko/docs/chapter_dynamic_programming/summary.md new file mode 100644 index 000000000..b32a7308b --- /dev/null +++ b/ko/docs/chapter_dynamic_programming/summary.md @@ -0,0 +1,29 @@ +--- +comments: true +--- + +# 14.7   요약 + +### 1.   핵심 정리 + +- 동적 계획법은 문제를 나누고 부분 문제의 해를 저장하여 중복 계산을 피함으로써 계산 효율을 높인다. +- 시간을 고려하지 않는다면 모든 동적 계획법 문제는 백트래킹(완전 탐색)으로 풀 수 있다. 하지만 재귀 트리에 중복 부분 문제가 매우 많아 비효율적이다. 메모이제이션 리스트에 계산한 해를 저장하면 중복 부분 문제를 한 번만 계산하도록 보장할 수 있다. +- 메모이제이션을 이용한 탐색은 위에서 아래로 진행하는 재귀 풀이이며, 이에 대응하는 동적 계획법은 '표를 채우듯' 아래에서 위로 진행하는 반복 풀이이다. 현재 상태는 일부 주변 상태에만 의존하므로 $dp$ 테이블의 한 차원을 없애 공간 복잡도를 줄일 수 있다. +- 부분 문제로 나누는 것은 일반적인 알고리즘 아이디어이며, 분할 정복, 동적 계획법, 백트래킹에서 서로 다른 특성을 가진다. +- 동적 계획법 문제의 세 가지 주요 특성은 중복 부분 문제, 최적 부분 구조, 무후효성이다. +- 원래 문제의 최적해를 부분 문제의 최적해로 구성할 수 있다면 최적 부분 구조를 가진다. +- 무후효성이란 한 상태의 이후 전개가 현재 상태에만 의존하고 과거의 모든 상태와는 무관하다는 뜻이다. 많은 조합 최적화 문제는 무후효성을 갖지 않아 동적 계획법으로 빠르게 풀 수 없다. + +**배낭 문제** + +- 배낭 문제는 가장 대표적인 동적 계획법 문제 중 하나로, 0-1 배낭, 무한 배낭, 개수 제한 배낭 등의 변형이 있다. +- 0-1 배낭의 상태는 앞 $i$ 개 물건을 용량 $c$인 배낭에 담을 때의 최대 가치로 정의한다. 넣지 않기와 넣기라는 두 결정으로 최적 부분 구조와 상태 전이 식을 얻는다. 공간 최적화에서는 바로 위와 왼쪽 위 상태에 의존하므로, 왼쪽 위 값이 덮어써지지 않도록 리스트를 역순으로 순회해야 한다. +- 무한 배낭은 각 물건의 선택 횟수에 제한이 없어 물건을 넣는 상태 전이가 0-1 배낭과 다르다. 바로 위와 바로 왼쪽 상태에 의존하므로 공간 최적화에서는 정방향 순회를 해야 한다. +- 동전 교환은 무한 배낭의 변형이다. '최대' 가치 대신 '최소' 동전 수를 구하므로 상태 전이 식의 $\max()$를 $\min()$으로 바꾼다. 용량을 '넘지 않는' 것이 아니라 목표 금액을 '정확히' 만들어야 하므로, 만들 수 없는 유효하지 않은 해를 $amt + 1$로 나타낸다. +- 동전 교환 II는 '최소 동전 수' 대신 '동전 조합 수'를 구하므로 상태 전이 식의 $\min()$을 합 연산으로 바꾼다. + +**편집 거리 문제** + +- 편집 거리(Levenshtein 거리)는 두 문자열의 유사도를 측정하며, 한 문자열을 다른 문자열로 바꾸는 최소 편집 횟수로 정의된다. 편집 연산에는 추가, 삭제, 교체가 있다. +- 편집 거리의 상태는 $s$의 앞 $i$ 개 문자를 $t$의 앞 $j$ 개 문자로 바꾸는 최소 편집 횟수로 정의한다. $s[i-1] \ne t[j-1]$이면 추가, 삭제, 교체의 세 결정이 있고 각각 남는 부분 문제가 있다. 이를 바탕으로 최적 부분 구조와 상태 전이 식을 구한다. $s[i-1] = t[j-1]$이면 현재 문자를 편집할 필요가 없다. +- 편집 거리의 상태는 바로 위, 바로 왼쪽, 왼쪽 위 상태에 의존하므로 공간 최적화 후에는 정방향이나 역방향 순회만으로 올바른 전이를 할 수 없다. 변수를 하나 두어 왼쪽 위 상태를 임시 저장하면 무한 배낭과 같은 상황이 되어 정방향 순회가 가능하다. diff --git a/ko/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md b/ko/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md new file mode 100644 index 000000000..630ede7fa --- /dev/null +++ b/ko/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -0,0 +1,2351 @@ +--- +comments: true +--- + +# 14.5   무한 배낭 문제 + +이 절에서는 또 다른 대표적인 배낭 문제인 무한 배낭을 먼저 풀고, 그 특수한 경우인 동전 교환을 알아본다. + +## 14.5.1   무한 배낭 문제 + +!!! question "문제" + + 물건 $n$ 개와 용량이 $cap$인 배낭이 주어진다. $i$ 번째 물건의 무게는 $wgt[i-1]$, 가치는 $val[i-1]$이다. **각 물건을 반복해서 선택할 수 있을 때**, 용량 제한 안에 담을 수 있는 최대 가치를 구하라. 예시는 아래 그림(14-22)과 같다. + +![무한 배낭 문제 예시 데이터](unbounded_knapsack_problem.assets/unbounded_knapsack_example.png){ class="animation-figure" } + +

그림 14-22   무한 배낭 문제 예시 데이터

+ +### 1.   동적 계획법 접근 + +무한 배낭 문제는 0-1 배낭 문제와 매우 비슷하며, **물건을 선택하는 횟수에 제한이 없다는 점만 다르다**. + +- 0-1 배낭에서는 종류별 물건이 하나뿐이므로 물건 $i$를 넣은 뒤에는 앞의 $i-1$ 개 물건에서만 선택할 수 있다. +- 무한 배낭에서는 각 종류의 물건이 무한히 많으므로 물건 $i$를 넣은 뒤에도 **앞의 $i$ 개 물건에서 계속 선택할 수 있다**. + +무한 배낭의 규칙에 따라 상태 $[i, c]$의 변화는 두 경우로 나뉜다. + +- **물건 $i$를 넣지 않음**: 0-1 배낭과 동일하게 $[i-1, c]$로 전이한다. +- **물건 $i$를 넣음**: 0-1 배낭과 달리 $[i, c-wgt[i-1]]$로 전이한다. + +따라서 상태 전이 식은 다음과 같이 바뀐다. + +$$ +dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1]) +$$ + +### 2.   코드 구현 + +두 문제의 코드를 비교하면 상태 전이의 한 곳이 $i-1$에서 $i$로 바뀌었을 뿐 나머지는 완전히 같다. + +=== "Python" + + ```python title="unbounded_knapsack.py" + def unbounded_knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int: + """무한 배낭: 동적 계획법""" + n = len(wgt) + # dp 테이블을 초기화한다 + dp = [[0] * (cap + 1) for _ in range(n + 1)] + # 상태 전이 + for i in range(1, n + 1): + for c in range(1, cap + 1): + if wgt[i - 1] > c: + # 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c] + else: + # 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]) + return dp[n][cap] + ``` + +=== "C++" + + ```cpp title="unbounded_knapsack.cpp" + /* 무한 배낭: 동적 계획법 */ + int unboundedKnapsackDP(vector &wgt, vector &val, int cap) { + int n = wgt.size(); + // dp 테이블을 초기화한다 + vector> dp(n + 1, vector(cap + 1, 0)); + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[n][cap]; + } + ``` + +=== "Java" + + ```java title="unbounded_knapsack.java" + /* 무한 배낭: 동적 계획법 */ + int unboundedKnapsackDP(int[] wgt, int[] val, int cap) { + int n = wgt.length; + // dp 테이블을 초기화한다 + int[][] dp = new int[n + 1][cap + 1]; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = Math.max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[n][cap]; + } + ``` + +=== "C#" + + ```csharp title="unbounded_knapsack.cs" + /* 무한 배낭: 동적 계획법 */ + int UnboundedKnapsackDP(int[] wgt, int[] val, int cap) { + int n = wgt.Length; + // dp 테이블을 초기화한다 + int[,] dp = new int[n + 1, cap + 1]; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i, c] = dp[i - 1, c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i, c] = Math.Max(dp[i - 1, c], dp[i, c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[n, cap]; + } + ``` + +=== "Go" + + ```go title="unbounded_knapsack.go" + /* 무한 배낭: 동적 계획법 */ + func unboundedKnapsackDP(wgt, val []int, cap int) int { + n := len(wgt) + // dp 테이블을 초기화한다 + dp := make([][]int, n+1) + for i := 0; i <= n; i++ { + dp[i] = make([]int, cap+1) + } + // 상태 전이 + for i := 1; i <= n; i++ { + for c := 1; c <= cap; c++ { + if wgt[i-1] > c { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i-1][c] + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = int(math.Max(float64(dp[i-1][c]), float64(dp[i][c-wgt[i-1]]+val[i-1]))) + } + } + } + return dp[n][cap] + } + ``` + +=== "Swift" + + ```swift title="unbounded_knapsack.swift" + /* 무한 배낭: 동적 계획법 */ + func unboundedKnapsackDP(wgt: [Int], val: [Int], cap: Int) -> Int { + let n = wgt.count + // dp 테이블을 초기화한다 + var dp = Array(repeating: Array(repeating: 0, count: cap + 1), count: n + 1) + // 상태 전이 + for i in 1 ... n { + for c in 1 ... cap { + if wgt[i - 1] > c { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c] + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]) + } + } + } + return dp[n][cap] + } + ``` + +=== "JS" + + ```javascript title="unbounded_knapsack.js" + /* 무한 배낭: 동적 계획법 */ + function unboundedKnapsackDP(wgt, val, cap) { + const n = wgt.length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: n + 1 }, () => + Array.from({ length: cap + 1 }, () => 0) + ); + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = Math.max( + dp[i - 1][c], + dp[i][c - wgt[i - 1]] + val[i - 1] + ); + } + } + } + return dp[n][cap]; + } + ``` + +=== "TS" + + ```typescript title="unbounded_knapsack.ts" + /* 무한 배낭: 동적 계획법 */ + function unboundedKnapsackDP( + wgt: Array, + val: Array, + cap: number + ): number { + const n = wgt.length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: n + 1 }, () => + Array.from({ length: cap + 1 }, () => 0) + ); + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = Math.max( + dp[i - 1][c], + dp[i][c - wgt[i - 1]] + val[i - 1] + ); + } + } + } + return dp[n][cap]; + } + ``` + +=== "Dart" + + ```dart title="unbounded_knapsack.dart" + /* 무한 배낭: 동적 계획법 */ + int unboundedKnapsackDP(List wgt, List val, int cap) { + int n = wgt.length; + // dp 테이블을 초기화한다 + List> dp = List.generate(n + 1, (index) => List.filled(cap + 1, 0)); + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[n][cap]; + } + ``` + +=== "Rust" + + ```rust title="unbounded_knapsack.rs" + /* 무한 배낭: 동적 계획법 */ + fn unbounded_knapsack_dp(wgt: &[i32], val: &[i32], cap: usize) -> i32 { + let n = wgt.len(); + // dp 테이블을 초기화한다 + let mut dp = vec![vec![0; cap + 1]; n + 1]; + // 상태 전이 + for i in 1..=n { + for c in 1..=cap { + if wgt[i - 1] > c as i32 { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = std::cmp::max(dp[i - 1][c], dp[i][c - wgt[i - 1] as usize] + val[i - 1]); + } + } + } + return dp[n][cap]; + } + ``` + +=== "C" + + ```c title="unbounded_knapsack.c" + /* 무한 배낭: 동적 계획법 */ + int unboundedKnapsackDP(int wgt[], int val[], int cap, int wgtSize) { + int n = wgtSize; + // dp 테이블을 초기화한다 + int **dp = malloc((n + 1) * sizeof(int *)); + for (int i = 0; i <= n; i++) { + dp[i] = calloc(cap + 1, sizeof(int)); + } + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = myMax(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]); + } + } + } + int res = dp[n][cap]; + // 메모리 해제 + for (int i = 0; i <= n; i++) { + free(dp[i]); + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="unbounded_knapsack.kt" + /* 무한 배낭: 동적 계획법 */ + fun unboundedKnapsackDP(wgt: IntArray, _val: IntArray, cap: Int): Int { + val n = wgt.size + // dp 테이블을 초기화한다 + val dp = Array(n + 1) { IntArray(cap + 1) } + // 상태 전이 + for (i in 1..n) { + for (c in 1..cap) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c] + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + _val[i - 1]) + } + } + } + return dp[n][cap] + } + ``` + +=== "Ruby" + + ```ruby title="unbounded_knapsack.rb" + ### 무한 배낭: 동적 계획법 ### + def unbounded_knapsack_dp(wgt, val, cap) + n = wgt.length + # dp 테이블을 초기화한다 + dp = Array.new(n + 1) { Array.new(cap + 1, 0) } + # 상태 전이 + for i in 1...(n + 1) + for c in 1...(cap + 1) + if wgt[i - 1] > c + # 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c] + else + # 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = [dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]].max + end + end + end + dp[n][cap] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 3.   공간 최적화 + +현재 상태는 왼쪽과 위쪽 상태에서 전이되므로 **공간 최적화 후에는 $dp$ 테이블의 각 행을 정방향으로 순회해야 한다**. + +이는 0-1 배낭의 순회 순서와 정반대다. 아래 그림(14-23)으로 차이를 이해해 보자. + +=== "<1>" + ![공간 최적화 후 무한 배낭의 동적 계획법 과정](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step1.png){ class="animation-figure" } + +=== "<2>" + ![공간 최적화된 무한 배낭 2단계](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step2.png){ class="animation-figure" } + +=== "<3>" + ![공간 최적화된 무한 배낭 3단계](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step3.png){ class="animation-figure" } + +=== "<4>" + ![공간 최적화된 무한 배낭 4단계](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step4.png){ class="animation-figure" } + +=== "<5>" + ![공간 최적화된 무한 배낭 5단계](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step5.png){ class="animation-figure" } + +=== "<6>" + ![공간 최적화된 무한 배낭 6단계](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step6.png){ class="animation-figure" } + +

그림 14-23   공간 최적화 후 무한 배낭의 동적 계획법 과정

+ +구현은 간단하다. 배열 `dp`의 첫 번째 차원을 없애면 된다. + +=== "Python" + + ```python title="unbounded_knapsack.py" + def unbounded_knapsack_dp_comp(wgt: list[int], val: list[int], cap: int) -> int: + """무한 배낭: 공간을 최적화한 동적 계획법""" + n = len(wgt) + # dp 테이블을 초기화한다 + dp = [0] * (cap + 1) + # 상태 전이 + for i in range(1, n + 1): + # 정방향 순회 + for c in range(1, cap + 1): + if wgt[i - 1] > c: + # 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c] + else: + # 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]) + return dp[cap] + ``` + +=== "C++" + + ```cpp title="unbounded_knapsack.cpp" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + int unboundedKnapsackDPComp(vector &wgt, vector &val, int cap) { + int n = wgt.size(); + // dp 테이블을 초기화한다 + vector dp(cap + 1, 0); + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "Java" + + ```java title="unbounded_knapsack.java" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + int unboundedKnapsackDPComp(int[] wgt, int[] val, int cap) { + int n = wgt.length; + // dp 테이블을 초기화한다 + int[] dp = new int[cap + 1]; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "C#" + + ```csharp title="unbounded_knapsack.cs" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + int UnboundedKnapsackDPComp(int[] wgt, int[] val, int cap) { + int n = wgt.Length; + // dp 테이블을 초기화한다 + int[] dp = new int[cap + 1]; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = Math.Max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "Go" + + ```go title="unbounded_knapsack.go" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + func unboundedKnapsackDPComp(wgt, val []int, cap int) int { + n := len(wgt) + // dp 테이블을 초기화한다 + dp := make([]int, cap+1) + // 상태 전이 + for i := 1; i <= n; i++ { + for c := 1; c <= cap; c++ { + if wgt[i-1] > c { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c] + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = int(math.Max(float64(dp[c]), float64(dp[c-wgt[i-1]]+val[i-1]))) + } + } + } + return dp[cap] + } + ``` + +=== "Swift" + + ```swift title="unbounded_knapsack.swift" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + func unboundedKnapsackDPComp(wgt: [Int], val: [Int], cap: Int) -> Int { + let n = wgt.count + // dp 테이블을 초기화한다 + var dp = Array(repeating: 0, count: cap + 1) + // 상태 전이 + for i in 1 ... n { + for c in 1 ... cap { + if wgt[i - 1] > c { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c] + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]) + } + } + } + return dp[cap] + } + ``` + +=== "JS" + + ```javascript title="unbounded_knapsack.js" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + function unboundedKnapsackDPComp(wgt, val, cap) { + const n = wgt.length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: cap + 1 }, () => 0); + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "TS" + + ```typescript title="unbounded_knapsack.ts" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + function unboundedKnapsackDPComp( + wgt: Array, + val: Array, + cap: number + ): number { + const n = wgt.length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: cap + 1 }, () => 0); + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "Dart" + + ```dart title="unbounded_knapsack.dart" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + int unboundedKnapsackDPComp(List wgt, List val, int cap) { + int n = wgt.length; + // dp 테이블을 초기화한다 + List dp = List.filled(cap + 1, 0); + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } + ``` + +=== "Rust" + + ```rust title="unbounded_knapsack.rs" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + fn unbounded_knapsack_dp_comp(wgt: &[i32], val: &[i32], cap: usize) -> i32 { + let n = wgt.len(); + // dp 테이블을 초기화한다 + let mut dp = vec![0; cap + 1]; + // 상태 전이 + for i in 1..=n { + for c in 1..=cap { + if wgt[i - 1] > c as i32 { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = std::cmp::max(dp[c], dp[c - wgt[i - 1] as usize] + val[i - 1]); + } + } + } + dp[cap] + } + ``` + +=== "C" + + ```c title="unbounded_knapsack.c" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + int unboundedKnapsackDPComp(int wgt[], int val[], int cap, int wgtSize) { + int n = wgtSize; + // dp 테이블을 초기화한다 + int *dp = calloc(cap + 1, sizeof(int)); + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c]; + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = myMax(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + int res = dp[cap]; + // 메모리 해제 + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="unbounded_knapsack.kt" + /* 무한 배낭: 공간을 최적화한 동적 계획법 */ + fun unboundedKnapsackDPComp( + wgt: IntArray, + _val: IntArray, + cap: Int + ): Int { + val n = wgt.size + // dp 테이블을 초기화한다 + val dp = IntArray(cap + 1) + // 상태 전이 + for (i in 1..n) { + for (c in 1..cap) { + if (wgt[i - 1] > c) { + // 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c] + } else { + // 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + _val[i - 1]) + } + } + } + return dp[cap] + } + ``` + +=== "Ruby" + + ```ruby title="unbounded_knapsack.rb" + ### 무한 배낭: 동적 계획법 ### + def unbounded_knapsack_dp(wgt, val, cap) + n = wgt.length + # dp 테이블을 초기화한다 + dp = Array.new(n + 1) { Array.new(cap + 1, 0) } + # 상태 전이 + for i in 1...(n + 1) + for c in 1...(cap + 1) + if wgt[i - 1] > c + # 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[i][c] = dp[i - 1][c] + else + # 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[i][c] = [dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]].max + end + end + end + dp[n][cap] + end + + ### 무한 배낭: 공간을 최적화한 동적 계획법 ##3 + def unbounded_knapsack_dp_comp(wgt, val, cap) + n = wgt.length + # dp 테이블을 초기화한다 + dp = Array.new(cap + 1, 0) + # 상태 전이 + for i in 1...(n + 1) + # 정방향 순회 + for c in 1...(cap + 1) + if wgt[i -1] > c + # 배낭 용량을 초과하면 물건 i를 선택하지 않는다 + dp[c] = dp[c] + else + # 물건 i를 선택하지 않는 경우와 선택하는 경우 중 더 큰 값 + dp[c] = [dp[c], dp[c - wgt[i - 1]] + val[i - 1]].max + end + end + end + dp[cap] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 14.5.2   동전 교환 문제 + +배낭 문제는 동적 계획법의 큰 문제군을 대표하며, 동전 교환 같은 여러 변형이 있다. + +!!! question "문제" + + 동전 $n$ 종류가 주어지며 $i$ 번째 동전의 액면가는 $coins[i - 1]$이고 목표 금액은 $amt$다. **각 종류의 동전을 반복해서 선택할 수 있을 때**, 목표 금액을 만드는 최소 동전 수를 구하라. 목표 금액을 만들 수 없으면 $-1$을 반환하라. 예시는 아래 그림(14-24)과 같다. + +![동전 교환 문제 예시 데이터](unbounded_knapsack_problem.assets/coin_change_example.png){ class="animation-figure" } + +

그림 14-24   동전 교환 문제 예시 데이터

+ +### 1.   동적 계획법 접근 + +**동전 교환은 무한 배낭 문제의 특수한 경우로 볼 수 있으며**, 다음과 같은 공통점과 차이점이 있다. + +- 두 문제는 서로 변환할 수 있다. '물건'은 '동전', '물건 무게'는 '동전 액면가', '배낭 용량'은 '목표 금액'에 대응한다. +- 최적화 목표가 반대다. 무한 배낭은 물건 가치를 최대화하고, 동전 교환은 동전 수를 최소화한다. +- 무한 배낭은 용량을 '넘지 않는' 해를 구하지만, 동전 교환은 목표 금액을 '정확히' 만드는 해를 구한다. + +**1단계: 매 차례의 결정을 생각하고 상태를 정의하여 $dp$ 테이블을 얻는다** + +상태 $[i, a]$의 부분 문제는 **앞의 $i$ 종류 동전으로 금액 $a$를 만드는 최소 동전 수**이며, 이를 $dp[i, a]$라고 한다. + +2차원 $dp$ 테이블의 크기는 $(n+1) \times (amt+1)$이다. + +**2단계: 최적 부분 구조를 찾아 상태 전이 식을 도출한다** + +이 문제의 상태 전이 식은 무한 배낭과 다음 두 가지가 다르다. + +- 최솟값을 구하므로 연산자 $\max()$를 $\min()$으로 바꾼다. +- 최적화 대상은 상품 가치가 아니라 동전 수이므로, 동전을 선택하면 $+1$만 수행한다. + +$$ +dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1) +$$ + +**3단계: 경계 조건과 상태 전이 순서를 정한다** + +목표 금액이 $0$이면 최소 동전 수는 $0$이다. 즉, 첫 열의 모든 $dp[i, 0]$은 $0$이다. + +동전이 없으면 **$> 0$인 어떤 목표 금액도 만들 수 없으므로** 유효하지 않은 해다. 상태 전이 식의 $\min()$이 이를 알아보고 제외하도록 $+ \infty$로 나타낸다. 즉, 첫 행의 모든 $dp[0, a]$를 $+ \infty$로 둔다. + +### 2.   코드 구현 + +대부분의 프로그래밍 언어는 $+ \infty$ 변수를 제공하지 않아 정수 타입 `int`의 최댓값으로 대신해야 한다. 하지만 그러면 상태 전이 식의 $+ 1$ 연산에서 정수 오버플로가 발생할 수 있다. + +이를 피하기 위해 유효하지 않은 해를 $amt + 1$로 나타낸다. $amt$를 만드는 동전 수는 많아도 $amt$ 개이기 때문이다. 마지막에 $dp[n, amt]$가 $amt + 1$인지 확인하고, 같으면 목표 금액을 만들 수 없다는 뜻으로 $-1$을 반환한다. 코드는 다음과 같다. + +=== "Python" + + ```python title="coin_change.py" + def coin_change_dp(coins: list[int], amt: int) -> int: + """동전 교환: 동적 계획법""" + n = len(coins) + MAX = amt + 1 + # dp 테이블을 초기화한다 + dp = [[0] * (amt + 1) for _ in range(n + 1)] + # 상태 전이: 첫 번째 행과 첫 번째 열 + for a in range(1, amt + 1): + dp[0][a] = MAX + # 상태 전이: 나머지 행과 열 + for i in range(1, n + 1): + for a in range(1, amt + 1): + if coins[i - 1] > a: + # 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a] + else: + # 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1) + return dp[n][amt] if dp[n][amt] != MAX else -1 + ``` + +=== "C++" + + ```cpp title="coin_change.cpp" + /* 동전 교환: 동적 계획법 */ + int coinChangeDP(vector &coins, int amt) { + int n = coins.size(); + int MAX = amt + 1; + // dp 테이블을 초기화한다 + vector> dp(n + 1, vector(amt + 1, 0)); + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int a = 1; a <= amt; a++) { + dp[0][a] = MAX; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1); + } + } + } + return dp[n][amt] != MAX ? dp[n][amt] : -1; + } + ``` + +=== "Java" + + ```java title="coin_change.java" + /* 동전 교환: 동적 계획법 */ + int coinChangeDP(int[] coins, int amt) { + int n = coins.length; + int MAX = amt + 1; + // dp 테이블을 초기화한다 + int[][] dp = new int[n + 1][amt + 1]; + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int a = 1; a <= amt; a++) { + dp[0][a] = MAX; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = Math.min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1); + } + } + } + return dp[n][amt] != MAX ? dp[n][amt] : -1; + } + ``` + +=== "C#" + + ```csharp title="coin_change.cs" + /* 동전 교환: 동적 계획법 */ + int CoinChangeDP(int[] coins, int amt) { + int n = coins.Length; + int MAX = amt + 1; + // dp 테이블을 초기화한다 + int[,] dp = new int[n + 1, amt + 1]; + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int a = 1; a <= amt; a++) { + dp[0, a] = MAX; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i, a] = dp[i - 1, a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i, a] = Math.Min(dp[i - 1, a], dp[i, a - coins[i - 1]] + 1); + } + } + } + return dp[n, amt] != MAX ? dp[n, amt] : -1; + } + ``` + +=== "Go" + + ```go title="coin_change.go" + /* 동전 교환: 동적 계획법 */ + func coinChangeDP(coins []int, amt int) int { + n := len(coins) + max := amt + 1 + // dp 테이블을 초기화한다 + dp := make([][]int, n+1) + for i := 0; i <= n; i++ { + dp[i] = make([]int, amt+1) + } + // 상태 전이: 첫 번째 행과 첫 번째 열 + for a := 1; a <= amt; a++ { + dp[0][a] = max + } + // 상태 전이: 나머지 행과 열 + for i := 1; i <= n; i++ { + for a := 1; a <= amt; a++ { + if coins[i-1] > a { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i-1][a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = int(math.Min(float64(dp[i-1][a]), float64(dp[i][a-coins[i-1]]+1))) + } + } + } + if dp[n][amt] != max { + return dp[n][amt] + } + return -1 + } + ``` + +=== "Swift" + + ```swift title="coin_change.swift" + /* 동전 교환: 동적 계획법 */ + func coinChangeDP(coins: [Int], amt: Int) -> Int { + let n = coins.count + let MAX = amt + 1 + // dp 테이블을 초기화한다 + var dp = Array(repeating: Array(repeating: 0, count: amt + 1), count: n + 1) + // 상태 전이: 첫 번째 행과 첫 번째 열 + for a in 1 ... amt { + dp[0][a] = MAX + } + // 상태 전이: 나머지 행과 열 + for i in 1 ... n { + for a in 1 ... amt { + if coins[i - 1] > a { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1) + } + } + } + return dp[n][amt] != MAX ? dp[n][amt] : -1 + } + ``` + +=== "JS" + + ```javascript title="coin_change.js" + /* 동전 교환: 동적 계획법 */ + function coinChangeDP(coins, amt) { + const n = coins.length; + const MAX = amt + 1; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: n + 1 }, () => + Array.from({ length: amt + 1 }, () => 0) + ); + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (let a = 1; a <= amt; a++) { + dp[0][a] = MAX; + } + // 상태 전이: 나머지 행과 열 + for (let i = 1; i <= n; i++) { + for (let a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = Math.min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1); + } + } + } + return dp[n][amt] !== MAX ? dp[n][amt] : -1; + } + ``` + +=== "TS" + + ```typescript title="coin_change.ts" + /* 동전 교환: 동적 계획법 */ + function coinChangeDP(coins: Array, amt: number): number { + const n = coins.length; + const MAX = amt + 1; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: n + 1 }, () => + Array.from({ length: amt + 1 }, () => 0) + ); + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (let a = 1; a <= amt; a++) { + dp[0][a] = MAX; + } + // 상태 전이: 나머지 행과 열 + for (let i = 1; i <= n; i++) { + for (let a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = Math.min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1); + } + } + } + return dp[n][amt] !== MAX ? dp[n][amt] : -1; + } + ``` + +=== "Dart" + + ```dart title="coin_change.dart" + /* 동전 교환: 동적 계획법 */ + int coinChangeDP(List coins, int amt) { + int n = coins.length; + int MAX = amt + 1; + // dp 테이블을 초기화한다 + List> dp = List.generate(n + 1, (index) => List.filled(amt + 1, 0)); + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int a = 1; a <= amt; a++) { + dp[0][a] = MAX; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1); + } + } + } + return dp[n][amt] != MAX ? dp[n][amt] : -1; + } + ``` + +=== "Rust" + + ```rust title="coin_change.rs" + /* 동전 교환: 동적 계획법 */ + fn coin_change_dp(coins: &[i32], amt: usize) -> i32 { + let n = coins.len(); + let max = amt + 1; + // dp 테이블을 초기화한다 + let mut dp = vec![vec![0; amt + 1]; n + 1]; + // 상태 전이: 첫 번째 행과 첫 번째 열 + for a in 1..=amt { + dp[0][a] = max; + } + // 상태 전이: 나머지 행과 열 + for i in 1..=n { + for a in 1..=amt { + if coins[i - 1] > a as i32 { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = std::cmp::min(dp[i - 1][a], dp[i][a - coins[i - 1] as usize] + 1); + } + } + } + if dp[n][amt] != max { + return dp[n][amt] as i32; + } else { + -1 + } + } + ``` + +=== "C" + + ```c title="coin_change.c" + /* 동전 교환: 동적 계획법 */ + int coinChangeDP(int coins[], int amt, int coinsSize) { + int n = coinsSize; + int MAX = amt + 1; + // dp 테이블을 초기화한다 + int **dp = malloc((n + 1) * sizeof(int *)); + for (int i = 0; i <= n; i++) { + dp[i] = calloc(amt + 1, sizeof(int)); + } + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (int a = 1; a <= amt; a++) { + dp[0][a] = MAX; + } + // 상태 전이: 나머지 행과 열 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = myMin(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1); + } + } + } + int res = dp[n][amt] != MAX ? dp[n][amt] : -1; + // 메모리 해제 + for (int i = 0; i <= n; i++) { + free(dp[i]); + } + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="coin_change.kt" + /* 동전 교환: 동적 계획법 */ + fun coinChangeDP(coins: IntArray, amt: Int): Int { + val n = coins.size + val MAX = amt + 1 + // dp 테이블을 초기화한다 + val dp = Array(n + 1) { IntArray(amt + 1) } + // 상태 전이: 첫 번째 행과 첫 번째 열 + for (a in 1..amt) { + dp[0][a] = MAX + } + // 상태 전이: 나머지 행과 열 + for (i in 1..n) { + for (a in 1..amt) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1) + } + } + } + return if (dp[n][amt] != MAX) dp[n][amt] else -1 + } + ``` + +=== "Ruby" + + ```ruby title="coin_change.rb" + ### 동전 교환: 동적 계획법 ### + def coin_change_dp(coins, amt) + n = coins.length + _MAX = amt + 1 + # dp 테이블을 초기화한다 + dp = Array.new(n + 1) { Array.new(amt + 1, 0) } + # 상태 전이: 첫 번째 행과 첫 번째 열 + (1...(amt + 1)).each { |a| dp[0][a] = _MAX } + # 상태 전이: 나머지 행과 열 + for i in 1...(n + 1) + for a in 1...(amt + 1) + if coins[i - 1] > a + # 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a] + else + # 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[i][a] = [dp[i - 1][a], dp[i][a - coins[i - 1]] + 1].min + end + end + end + dp[n][amt] != _MAX ? dp[n][amt] : -1 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(14-25)의 동전 교환 동적 계획법 과정은 무한 배낭 문제와 매우 비슷하다. + +=== "<1>" + ![동전 교환 문제의 동적 계획법 과정](unbounded_knapsack_problem.assets/coin_change_dp_step1.png){ class="animation-figure" } + +=== "<2>" + ![동전 교환 동적 계획법 2단계](unbounded_knapsack_problem.assets/coin_change_dp_step2.png){ class="animation-figure" } + +=== "<3>" + ![동전 교환 동적 계획법 3단계](unbounded_knapsack_problem.assets/coin_change_dp_step3.png){ class="animation-figure" } + +=== "<4>" + ![동전 교환 동적 계획법 4단계](unbounded_knapsack_problem.assets/coin_change_dp_step4.png){ class="animation-figure" } + +=== "<5>" + ![동전 교환 동적 계획법 5단계](unbounded_knapsack_problem.assets/coin_change_dp_step5.png){ class="animation-figure" } + +=== "<6>" + ![동전 교환 동적 계획법 6단계](unbounded_knapsack_problem.assets/coin_change_dp_step6.png){ class="animation-figure" } + +=== "<7>" + ![동전 교환 동적 계획법 7단계](unbounded_knapsack_problem.assets/coin_change_dp_step7.png){ class="animation-figure" } + +=== "<8>" + ![동전 교환 동적 계획법 8단계](unbounded_knapsack_problem.assets/coin_change_dp_step8.png){ class="animation-figure" } + +=== "<9>" + ![동전 교환 동적 계획법 9단계](unbounded_knapsack_problem.assets/coin_change_dp_step9.png){ class="animation-figure" } + +=== "<10>" + ![동전 교환 동적 계획법 10단계](unbounded_knapsack_problem.assets/coin_change_dp_step10.png){ class="animation-figure" } + +=== "<11>" + ![동전 교환 동적 계획법 11단계](unbounded_knapsack_problem.assets/coin_change_dp_step11.png){ class="animation-figure" } + +=== "<12>" + ![동전 교환 동적 계획법 12단계](unbounded_knapsack_problem.assets/coin_change_dp_step12.png){ class="animation-figure" } + +=== "<13>" + ![동전 교환 동적 계획법 13단계](unbounded_knapsack_problem.assets/coin_change_dp_step13.png){ class="animation-figure" } + +=== "<14>" + ![동전 교환 동적 계획법 14단계](unbounded_knapsack_problem.assets/coin_change_dp_step14.png){ class="animation-figure" } + +=== "<15>" + ![동전 교환 동적 계획법 15단계](unbounded_knapsack_problem.assets/coin_change_dp_step15.png){ class="animation-figure" } + +

그림 14-25   동전 교환 문제의 동적 계획법 과정

+ +### 3.   공간 최적화 + +동전 교환의 공간 최적화는 무한 배낭과 같은 방식으로 처리한다. + +=== "Python" + + ```python title="coin_change.py" + def coin_change_dp_comp(coins: list[int], amt: int) -> int: + """동전 교환: 공간을 최적화한 동적 계획법""" + n = len(coins) + MAX = amt + 1 + # dp 테이블을 초기화한다 + dp = [MAX] * (amt + 1) + dp[0] = 0 + # 상태 전이 + for i in range(1, n + 1): + # 정방향 순회 + for a in range(1, amt + 1): + if coins[i - 1] > a: + # 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + else: + # 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = min(dp[a], dp[a - coins[i - 1]] + 1) + return dp[amt] if dp[amt] != MAX else -1 + ``` + +=== "C++" + + ```cpp title="coin_change.cpp" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + int coinChangeDPComp(vector &coins, int amt) { + int n = coins.size(); + int MAX = amt + 1; + // dp 테이블을 초기화한다 + vector dp(amt + 1, MAX); + dp[0] = 0; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = min(dp[a], dp[a - coins[i - 1]] + 1); + } + } + } + return dp[amt] != MAX ? dp[amt] : -1; + } + ``` + +=== "Java" + + ```java title="coin_change.java" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + int coinChangeDPComp(int[] coins, int amt) { + int n = coins.length; + int MAX = amt + 1; + // dp 테이블을 초기화한다 + int[] dp = new int[amt + 1]; + Arrays.fill(dp, MAX); + dp[0] = 0; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = Math.min(dp[a], dp[a - coins[i - 1]] + 1); + } + } + } + return dp[amt] != MAX ? dp[amt] : -1; + } + ``` + +=== "C#" + + ```csharp title="coin_change.cs" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + int CoinChangeDPComp(int[] coins, int amt) { + int n = coins.Length; + int MAX = amt + 1; + // dp 테이블을 초기화한다 + int[] dp = new int[amt + 1]; + Array.Fill(dp, MAX); + dp[0] = 0; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = Math.Min(dp[a], dp[a - coins[i - 1]] + 1); + } + } + } + return dp[amt] != MAX ? dp[amt] : -1; + } + ``` + +=== "Go" + + ```go title="coin_change.go" + /* 동전 교환: 동적 계획법 */ + func coinChangeDPComp(coins []int, amt int) int { + n := len(coins) + max := amt + 1 + // dp 테이블을 초기화한다 + dp := make([]int, amt+1) + for i := 1; i <= amt; i++ { + dp[i] = max + } + // 상태 전이 + for i := 1; i <= n; i++ { + // 정방향 순회 + for a := 1; a <= amt; a++ { + if coins[i-1] > a { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = int(math.Min(float64(dp[a]), float64(dp[a-coins[i-1]]+1))) + } + } + } + if dp[amt] != max { + return dp[amt] + } + return -1 + } + ``` + +=== "Swift" + + ```swift title="coin_change.swift" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + func coinChangeDPComp(coins: [Int], amt: Int) -> Int { + let n = coins.count + let MAX = amt + 1 + // dp 테이블을 초기화한다 + var dp = Array(repeating: MAX, count: amt + 1) + dp[0] = 0 + // 상태 전이 + for i in 1 ... n { + for a in 1 ... amt { + if coins[i - 1] > a { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = min(dp[a], dp[a - coins[i - 1]] + 1) + } + } + } + return dp[amt] != MAX ? dp[amt] : -1 + } + ``` + +=== "JS" + + ```javascript title="coin_change.js" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + function coinChangeDPComp(coins, amt) { + const n = coins.length; + const MAX = amt + 1; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: amt + 1 }, () => MAX); + dp[0] = 0; + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = Math.min(dp[a], dp[a - coins[i - 1]] + 1); + } + } + } + return dp[amt] !== MAX ? dp[amt] : -1; + } + ``` + +=== "TS" + + ```typescript title="coin_change.ts" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + function coinChangeDPComp(coins: Array, amt: number): number { + const n = coins.length; + const MAX = amt + 1; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: amt + 1 }, () => MAX); + dp[0] = 0; + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = Math.min(dp[a], dp[a - coins[i - 1]] + 1); + } + } + } + return dp[amt] !== MAX ? dp[amt] : -1; + } + ``` + +=== "Dart" + + ```dart title="coin_change.dart" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + int coinChangeDPComp(List coins, int amt) { + int n = coins.length; + int MAX = amt + 1; + // dp 테이블을 초기화한다 + List dp = List.filled(amt + 1, MAX); + dp[0] = 0; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = min(dp[a], dp[a - coins[i - 1]] + 1); + } + } + } + return dp[amt] != MAX ? dp[amt] : -1; + } + ``` + +=== "Rust" + + ```rust title="coin_change.rs" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + fn coin_change_dp_comp(coins: &[i32], amt: usize) -> i32 { + let n = coins.len(); + let max = amt + 1; + // dp 테이블을 초기화한다 + let mut dp = vec![0; amt + 1]; + dp.fill(max); + dp[0] = 0; + // 상태 전이 + for i in 1..=n { + for a in 1..=amt { + if coins[i - 1] > a as i32 { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = std::cmp::min(dp[a], dp[a - coins[i - 1] as usize] + 1); + } + } + } + if dp[amt] != max { + return dp[amt] as i32; + } else { + -1 + } + } + ``` + +=== "C" + + ```c title="coin_change.c" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + int coinChangeDPComp(int coins[], int amt, int coinsSize) { + int n = coinsSize; + int MAX = amt + 1; + // dp 테이블을 초기화한다 + int *dp = malloc((amt + 1) * sizeof(int)); + for (int j = 1; j <= amt; j++) { + dp[j] = MAX; + } + dp[0] = 0; + + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = myMin(dp[a], dp[a - coins[i - 1]] + 1); + } + } + } + int res = dp[amt] != MAX ? dp[amt] : -1; + // 메모리 해제 + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="coin_change.kt" + /* 동전 교환: 공간을 최적화한 동적 계획법 */ + fun coinChangeDPComp(coins: IntArray, amt: Int): Int { + val n = coins.size + val MAX = amt + 1 + // dp 테이블을 초기화한다 + val dp = IntArray(amt + 1) + dp.fill(MAX) + dp[0] = 0 + // 상태 전이 + for (i in 1..n) { + for (a in 1..amt) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = min(dp[a], dp[a - coins[i - 1]] + 1) + } + } + } + return if (dp[amt] != MAX) dp[amt] else -1 + } + ``` + +=== "Ruby" + + ```ruby title="coin_change.rb" + ### 동전 교환: 공간을 최적화한 동적 계획법 ### + def coin_change_dp_comp(coins, amt) + n = coins.length + _MAX = amt + 1 + # dp 테이블을 초기화한다 + dp = Array.new(amt + 1, _MAX) + dp[0] = 0 + # 상태 전이 + for i in 1...(n + 1) + # 정방향 순회 + for a in 1...(amt + 1) + if coins[i - 1] > a + # 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + else + # 동전 i를 선택하지 않는 경우와 선택하는 경우 중 더 작은 값 + dp[a] = [dp[a], dp[a - coins[i - 1]] + 1].min + end + end + end + dp[amt] != _MAX ? dp[amt] : -1 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 14.5.3   동전 교환 문제 II + +!!! question "문제" + + 동전 $n$ 종류가 주어지며 $i$ 번째 동전의 액면가는 $coins[i - 1]$이고 목표 금액은 $amt$다. 각 종류의 동전을 반복해서 선택할 수 있을 때, **목표 금액을 만드는 동전 조합 수를 구하라**. 예시는 아래 그림(14-26)과 같다. + +![동전 교환 문제 II 예시 데이터](unbounded_knapsack_problem.assets/coin_change_ii_example.png){ class="animation-figure" } + +

그림 14-26   동전 교환 문제 II 예시 데이터

+ +### 1.   동적 계획법 접근 + +앞 문제와 달리 이번에는 조합 수를 구하므로 부분 문제는 **앞의 $i$ 종류 동전으로 금액 $a$를 만드는 조합 수**가 된다. $dp$ 테이블은 여전히 $(n+1) \times (amt + 1)$ 크기의 2차원 행렬이다. + +현재 상태의 조합 수는 현재 동전을 선택하지 않는 경우와 선택하는 경우의 조합 수를 더한 값이다. 상태 전이 식은 다음과 같다. + +$$ +dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]] +$$ + +목표 금액이 $0$이면 동전을 하나도 선택하지 않고도 만들 수 있으므로 첫 열의 모든 $dp[i, 0]$을 $1$로 초기화한다. 동전이 없으면 $>0$인 목표 금액을 만들 수 없으므로 첫 행의 모든 $dp[0, a]$는 $0$이다. + +### 2.   코드 구현 + +=== "Python" + + ```python title="coin_change_ii.py" + def coin_change_ii_dp(coins: list[int], amt: int) -> int: + """동전 교환 II: 동적 계획법""" + n = len(coins) + # dp 테이블을 초기화한다 + dp = [[0] * (amt + 1) for _ in range(n + 1)] + # 첫 번째 열을 초기화한다 + for i in range(n + 1): + dp[i][0] = 1 + # 상태 전이 + for i in range(1, n + 1): + for a in range(1, amt + 1): + if coins[i - 1] > a: + # 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a] + else: + # 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]] + return dp[n][amt] + ``` + +=== "C++" + + ```cpp title="coin_change_ii.cpp" + /* 동전 교환 II: 동적 계획법 */ + int coinChangeIIDP(vector &coins, int amt) { + int n = coins.size(); + // dp 테이블을 초기화한다 + vector> dp(n + 1, vector(amt + 1, 0)); + // 첫 번째 열을 초기화한다 + for (int i = 0; i <= n; i++) { + dp[i][0] = 1; + } + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]]; + } + } + } + return dp[n][amt]; + } + ``` + +=== "Java" + + ```java title="coin_change_ii.java" + /* 동전 교환 II: 동적 계획법 */ + int coinChangeIIDP(int[] coins, int amt) { + int n = coins.length; + // dp 테이블을 초기화한다 + int[][] dp = new int[n + 1][amt + 1]; + // 첫 번째 열을 초기화한다 + for (int i = 0; i <= n; i++) { + dp[i][0] = 1; + } + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]]; + } + } + } + return dp[n][amt]; + } + ``` + +=== "C#" + + ```csharp title="coin_change_ii.cs" + /* 동전 교환 II: 동적 계획법 */ + int CoinChangeIIDP(int[] coins, int amt) { + int n = coins.Length; + // dp 테이블을 초기화한다 + int[,] dp = new int[n + 1, amt + 1]; + // 첫 번째 열을 초기화한다 + for (int i = 0; i <= n; i++) { + dp[i, 0] = 1; + } + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i, a] = dp[i - 1, a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i, a] = dp[i - 1, a] + dp[i, a - coins[i - 1]]; + } + } + } + return dp[n, amt]; + } + ``` + +=== "Go" + + ```go title="coin_change_ii.go" + /* 동전 교환 II: 동적 계획법 */ + func coinChangeIIDP(coins []int, amt int) int { + n := len(coins) + // dp 테이블을 초기화한다 + dp := make([][]int, n+1) + for i := 0; i <= n; i++ { + dp[i] = make([]int, amt+1) + } + // 첫 번째 열을 초기화한다 + for i := 0; i <= n; i++ { + dp[i][0] = 1 + } + // 상태 전이: 나머지 행과 열 + for i := 1; i <= n; i++ { + for a := 1; a <= amt; a++ { + if coins[i-1] > a { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i-1][a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i-1][a] + dp[i][a-coins[i-1]] + } + } + } + return dp[n][amt] + } + ``` + +=== "Swift" + + ```swift title="coin_change_ii.swift" + /* 동전 교환 II: 동적 계획법 */ + func coinChangeIIDP(coins: [Int], amt: Int) -> Int { + let n = coins.count + // dp 테이블을 초기화한다 + var dp = Array(repeating: Array(repeating: 0, count: amt + 1), count: n + 1) + // 첫 번째 열을 초기화한다 + for i in 0 ... n { + dp[i][0] = 1 + } + // 상태 전이 + for i in 1 ... n { + for a in 1 ... amt { + if coins[i - 1] > a { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]] + } + } + } + return dp[n][amt] + } + ``` + +=== "JS" + + ```javascript title="coin_change_ii.js" + /* 동전 교환 II: 동적 계획법 */ + function coinChangeIIDP(coins, amt) { + const n = coins.length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: n + 1 }, () => + Array.from({ length: amt + 1 }, () => 0) + ); + // 첫 번째 열을 초기화한다 + for (let i = 0; i <= n; i++) { + dp[i][0] = 1; + } + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]]; + } + } + } + return dp[n][amt]; + } + ``` + +=== "TS" + + ```typescript title="coin_change_ii.ts" + /* 동전 교환 II: 동적 계획법 */ + function coinChangeIIDP(coins: Array, amt: number): number { + const n = coins.length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: n + 1 }, () => + Array.from({ length: amt + 1 }, () => 0) + ); + // 첫 번째 열을 초기화한다 + for (let i = 0; i <= n; i++) { + dp[i][0] = 1; + } + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]]; + } + } + } + return dp[n][amt]; + } + ``` + +=== "Dart" + + ```dart title="coin_change_ii.dart" + /* 동전 교환 II: 동적 계획법 */ + int coinChangeIIDP(List coins, int amt) { + int n = coins.length; + // dp 테이블을 초기화한다 + List> dp = List.generate(n + 1, (index) => List.filled(amt + 1, 0)); + // 첫 번째 열을 초기화한다 + for (int i = 0; i <= n; i++) { + dp[i][0] = 1; + } + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]]; + } + } + } + return dp[n][amt]; + } + ``` + +=== "Rust" + + ```rust title="coin_change_ii.rs" + /* 동전 교환 II: 동적 계획법 */ + fn coin_change_ii_dp(coins: &[i32], amt: usize) -> i32 { + let n = coins.len(); + // dp 테이블을 초기화한다 + let mut dp = vec![vec![0; amt + 1]; n + 1]; + // 첫 번째 열을 초기화한다 + for i in 0..=n { + dp[i][0] = 1; + } + // 상태 전이 + for i in 1..=n { + for a in 1..=amt { + if coins[i - 1] > a as i32 { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1] as usize]; + } + } + } + dp[n][amt] + } + ``` + +=== "C" + + ```c title="coin_change_ii.c" + /* 동전 교환 II: 동적 계획법 */ + int coinChangeIIDP(int coins[], int amt, int coinsSize) { + int n = coinsSize; + // dp 테이블을 초기화한다 + int **dp = malloc((n + 1) * sizeof(int *)); + for (int i = 0; i <= n; i++) { + dp[i] = calloc(amt + 1, sizeof(int)); + } + // 첫 번째 열을 초기화한다 + for (int i = 0; i <= n; i++) { + dp[i][0] = 1; + } + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]]; + } + } + } + int res = dp[n][amt]; + // 메모리 해제 + for (int i = 0; i <= n; i++) { + free(dp[i]); + } + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="coin_change_ii.kt" + /* 동전 교환 II: 동적 계획법 */ + fun coinChangeIIDP(coins: IntArray, amt: Int): Int { + val n = coins.size + // dp 테이블을 초기화한다 + val dp = Array(n + 1) { IntArray(amt + 1) } + // 첫 번째 열을 초기화한다 + for (i in 0..n) { + dp[i][0] = 1 + } + // 상태 전이 + for (i in 1..n) { + for (a in 1..amt) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]] + } + } + } + return dp[n][amt] + } + ``` + +=== "Ruby" + + ```ruby title="coin_change_ii.rb" + ### 동전 교환 II: 동적 계획법 ### + def coin_change_ii_dp(coins, amt) + n = coins.length + # dp 테이블을 초기화한다 + dp = Array.new(n + 1) { Array.new(amt + 1, 0) } + # 첫 번째 열을 초기화한다 + (0...(n + 1)).each { |i| dp[i][0] = 1 } + # 상태 전이 + for i in 1...(n + 1) + for a in 1...(amt + 1) + if coins[i - 1] > a + # 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[i][a] = dp[i - 1][a] + else + # 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]] + end + end + end + dp[n][amt] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 3.   공간 최적화 + +공간 최적화 방법도 같으며, 동전 차원을 없애면 된다. + +=== "Python" + + ```python title="coin_change_ii.py" + def coin_change_ii_dp_comp(coins: list[int], amt: int) -> int: + """동전 교환 II: 공간을 최적화한 동적 계획법""" + n = len(coins) + # dp 테이블을 초기화한다 + dp = [0] * (amt + 1) + dp[0] = 1 + # 상태 전이 + for i in range(1, n + 1): + # 정방향 순회 + for a in range(1, amt + 1): + if coins[i - 1] > a: + # 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + else: + # 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]] + return dp[amt] + ``` + +=== "C++" + + ```cpp title="coin_change_ii.cpp" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + int coinChangeIIDPComp(vector &coins, int amt) { + int n = coins.size(); + // dp 테이블을 초기화한다 + vector dp(amt + 1, 0); + dp[0] = 1; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]]; + } + } + } + return dp[amt]; + } + ``` + +=== "Java" + + ```java title="coin_change_ii.java" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + int coinChangeIIDPComp(int[] coins, int amt) { + int n = coins.length; + // dp 테이블을 초기화한다 + int[] dp = new int[amt + 1]; + dp[0] = 1; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]]; + } + } + } + return dp[amt]; + } + ``` + +=== "C#" + + ```csharp title="coin_change_ii.cs" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + int CoinChangeIIDPComp(int[] coins, int amt) { + int n = coins.Length; + // dp 테이블을 초기화한다 + int[] dp = new int[amt + 1]; + dp[0] = 1; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]]; + } + } + } + return dp[amt]; + } + ``` + +=== "Go" + + ```go title="coin_change_ii.go" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + func coinChangeIIDPComp(coins []int, amt int) int { + n := len(coins) + // dp 테이블을 초기화한다 + dp := make([]int, amt+1) + dp[0] = 1 + // 상태 전이 + for i := 1; i <= n; i++ { + // 정방향 순회 + for a := 1; a <= amt; a++ { + if coins[i-1] > a { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a-coins[i-1]] + } + } + } + return dp[amt] + } + ``` + +=== "Swift" + + ```swift title="coin_change_ii.swift" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + func coinChangeIIDPComp(coins: [Int], amt: Int) -> Int { + let n = coins.count + // dp 테이블을 초기화한다 + var dp = Array(repeating: 0, count: amt + 1) + dp[0] = 1 + // 상태 전이 + for i in 1 ... n { + for a in 1 ... amt { + if coins[i - 1] > a { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]] + } + } + } + return dp[amt] + } + ``` + +=== "JS" + + ```javascript title="coin_change_ii.js" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + function coinChangeIIDPComp(coins, amt) { + const n = coins.length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: amt + 1 }, () => 0); + dp[0] = 1; + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]]; + } + } + } + return dp[amt]; + } + ``` + +=== "TS" + + ```typescript title="coin_change_ii.ts" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + function coinChangeIIDPComp(coins: Array, amt: number): number { + const n = coins.length; + // dp 테이블을 초기화한다 + const dp = Array.from({ length: amt + 1 }, () => 0); + dp[0] = 1; + // 상태 전이 + for (let i = 1; i <= n; i++) { + for (let a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]]; + } + } + } + return dp[amt]; + } + ``` + +=== "Dart" + + ```dart title="coin_change_ii.dart" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + int coinChangeIIDPComp(List coins, int amt) { + int n = coins.length; + // dp 테이블을 초기화한다 + List dp = List.filled(amt + 1, 0); + dp[0] = 1; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]]; + } + } + } + return dp[amt]; + } + ``` + +=== "Rust" + + ```rust title="coin_change_ii.rs" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + fn coin_change_ii_dp_comp(coins: &[i32], amt: usize) -> i32 { + let n = coins.len(); + // dp 테이블을 초기화한다 + let mut dp = vec![0; amt + 1]; + dp[0] = 1; + // 상태 전이 + for i in 1..=n { + for a in 1..=amt { + if coins[i - 1] > a as i32 { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1] as usize]; + } + } + } + dp[amt] + } + ``` + +=== "C" + + ```c title="coin_change_ii.c" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + int coinChangeIIDPComp(int coins[], int amt, int coinsSize) { + int n = coinsSize; + // dp 테이블을 초기화한다 + int *dp = calloc(amt + 1, sizeof(int)); + dp[0] = 1; + // 상태 전이 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a]; + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]]; + } + } + } + int res = dp[amt]; + // 메모리 해제 + free(dp); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="coin_change_ii.kt" + /* 동전 교환 II: 공간을 최적화한 동적 계획법 */ + fun coinChangeIIDPComp(coins: IntArray, amt: Int): Int { + val n = coins.size + // dp 테이블을 초기화한다 + val dp = IntArray(amt + 1) + dp[0] = 1 + // 상태 전이 + for (i in 1..n) { + for (a in 1..amt) { + if (coins[i - 1] > a) { + // 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + } else { + // 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]] + } + } + } + return dp[amt] + } + ``` + +=== "Ruby" + + ```ruby title="coin_change_ii.rb" + ### 동전 교환 II: 공간을 최적화한 동적 계획법 ### + def coin_change_ii_dp_comp(coins, amt) + n = coins.length + # dp 테이블을 초기화한다 + dp = Array.new(amt + 1, 0) + dp[0] = 1 + # 상태 전이 + for i in 1...(n + 1) + # 정방향 순회 + for a in 1...(amt + 1) + if coins[i - 1] > a + # 목표 금액을 초과하면 동전 i를 선택하지 않는다 + dp[a] = dp[a] + else + # 동전 i를 선택하지 않는 경우와 선택하는 경우의 방법 수를 더한다 + dp[a] = dp[a] + dp[a - coins[i - 1]] + end + end + end + dp[amt] + end + ``` + +??? pythontutor "코드 시각화" + +
+ diff --git a/ko/docs/chapter_graph/exercises.md b/ko/docs/chapter_graph/exercises.md new file mode 100644 index 000000000..2f1465a33 --- /dev/null +++ b/ko/docs/chapter_graph/exercises.md @@ -0,0 +1,107 @@ +--- +comments: true +--- + +# 9.5   연습 문제 + +## 9.5.1   개념 확인 + +### 1.   같은 그래프를 두 가지 방식으로 표현하기 + +무방향 그래프의 정점은 `A, B, C, D` 4개이며, 간선은 +`A-B, A-C, B-C, C-D`다. + + + +1. 이 그래프의 인접 리스트를 적어 보자. +2. 0과 1만 들어가는 인접 행렬을 완성해 보자. +3. `A`와 `D`가 직접 연결되어 있는지 판단할 때, 저장된 항목 하나만 확인하면 되는 표현 방식은 무엇인가? +4. 정점이 많고 간선이 적다면 일반적으로 어느 표현 방식이 공간을 더 절약하는가? + +??? success "참고 답안" + + 1. 인접 리스트는 다음과 같다. + + ```text + A: B, C + B: A, C + C: A, B, D + D: C + ``` + + 2. 인접 행렬은 다음과 같다. + + | | A | B | C | D | + | --- | --- | --- | --- | --- | + | A | 0 | 1 | 1 | 0 | + | B | 1 | 0 | 1 | 0 | + | C | 1 | 1 | 0 | 1 | + | D | 0 | 0 | 1 | 0 | + + 3. 인접 행렬에서는 `A`행과 `D`열이 만나는 항목을 바로 확인할 수 있다. 따라서 임의의 두 정점이 직접 연결되어 있는지 판단하기에 적합하다. + + 4. 정점이 많고 간선이 적을 때 인접 리스트는 실제로 존재하는 간선만 기록한다. 따라서 모든 정점 쌍에 대해 자리를 마련하는 인접 행렬보다 일반적으로 공간을 더 절약한다. + +### 2.   너비 우선 탐색과 깊이 우선 탐색의 방문 순서 + +무방향 그래프의 정점은 `A, B, C, D, E`이며, 간선은 +`A-B, A-C, B-D, C-D, D-E`다. + +A에서 출발하고, 방문하지 않은 인접 정점이 여러 개면 알파벳순으로 선택한다고 하자. + + + +1. 너비 우선 탐색(BFS)의 방문 순서를 적어 보자. +2. 재귀 깊이 우선 탐색(DFS)의 방문 순서를 적어 보자. +3. 두 탐색 모두 이미 방문한 정점을 기록해야 하는 이유는 무엇인가? + +??? success "참고 답안" + + 1. BFS의 방문 순서는 `A, B, C, D, E`다. A에서 간선 하나로 도달하는 B와 C를 먼저 방문하고, + 그다음 더 멀리 있는 D와 E를 방문한다. + + 2. DFS의 방문 순서는 `A, B, D, C, E`다. 현재 정점에서 방문하지 않은 인접 정점으로 차례대로 들어가므로 + 먼저 `A → B → D → C`를 따라간다. C에서 새로 방문할 인접 정점이 없으면 D로 돌아와 E를 방문한다. + + 3. 그래프에는 `A-B-D-C-A`와 같은 사이클이 있다. 이미 방문한 정점을 기록하지 않으면 + 같은 정점들을 사이클을 따라 반복해서 방문하여 탐색이 정상적으로 끝나지 않을 수 있다. + +### 3.   BFS 한 번으로 그래프 전체를 방문할 수 있는가 + +무방향 그래프의 정점은 `A, B, C, D, E, F`이며, 간선은 +`A-B, B-C, D-E`뿐이다. + + + +1. A에서 BFS를 한 번 시작하면 어떤 정점을 방문할 수 있는가? +2. 1번의 결과를 바탕으로, 이 BFS가 그래프의 모든 정점을 방문했는지 판단하고 이유를 설명하라. +3. 모든 정점을 알파벳순으로 확인하면서 방문하지 않은 정점을 만날 때마다 BFS를 새로 시작한다면, + 각 BFS의 시작 정점은 무엇인가? 이 그래프는 서로 연결되지 않은 부분인 연결 요소 몇 개로 이루어져 있는가? + +??? success "참고 답안" + + 1. A에서 출발하면 `A, B, C`만 방문할 수 있다. + + 2. 모든 정점을 방문한 것은 아니다. `D, E`는 서로 연결된 별개의 부분을 이루고, F는 홀로 떨어진 정점이다. + 이 정점들과 A 사이에는 경로가 없으므로 A에서 출발해서는 도달할 수 없다. + + 3. 세 번의 BFS는 차례대로 `A, D, F`에서 시작하여 각각 + `{A, B, C}`, `{D, E}`, `{F}`를 방문한다. 따라서 이 그래프의 연결 요소는 3개다. + +## 9.5.2   프로그래밍 연습 + +### 1.   무방향 그래프에 경로가 존재하는지 판단하기 + +정점이 $n$개인 무방향 그래프가 주어지며, 정점 번호는 $0$부터 $n-1$까지다. 배열 `edges`의 각 항목 `[u, v]`는 정점 `u`와 `v` 사이에 무방향 간선이 있음을 나타낸다. + +시작 정점 `source`와 도착 정점 `destination`도 주어진다. 먼저 `edges`로 인접 리스트를 만든 뒤 BFS 또는 DFS를 사용하여 +`source`에서 `destination`까지의 경로가 있는지 판단하라. 있으면 `true`를, 없으면 `false`를 반환하라. +그래프에는 사이클이 있을 수 있으며, 모든 정점이 서로 연결되어 있지 않을 수도 있다. + +??? tip "풀이 힌트" + + 1. 무방향 간선은 양쪽 방향을 모두 추가해야 한다. + 2. 그래프에는 사이클이 있을 수 있으므로 이미 방문한 노드를 반드시 기록해야 한다. + 3. source에서 출발하여 destination을 만나면 true를 반환한다. 탐색을 마칠 때까지 만나지 못하면 false를 반환한다. + +[LeetCode](https://leetcode.com/problems/find-if-path-exists-in-graph/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_graph/graph.md b/ko/docs/chapter_graph/graph.md new file mode 100644 index 000000000..24384a6f7 --- /dev/null +++ b/ko/docs/chapter_graph/graph.md @@ -0,0 +1,103 @@ +--- +comments: true +--- + +# 9.1   그래프 + +그래프(graph)는 정점(vertex)과 간선(edge)으로 이루어진 비선형 자료 구조다. 그래프 $G$는 정점 집합 $V$와 간선 집합 $E$로 추상적으로 표현할 수 있다. 다음 예시는 정점 5개와 간선 7개로 이루어진 그래프를 나타낸다. + +$$ +\begin{aligned} +V & = \{ 1, 2, 3, 4, 5 \} \newline +E & = \{ (1,2), (1,3), (1,5), (2,3), (2,4), (2,5), (4,5) \} \newline +G & = \{ V, E \} \newline +\end{aligned} +$$ + +정점을 노드로, 간선을 노드들을 연결하는 참조(포인터)로 보면 그래프는 연결 리스트를 확장한 자료 구조로 생각할 수 있다. 아래 그림(9-1)과 같이 **선형 관계(연결 리스트)와 분할 정복 관계(트리)에 비해 네트워크 관계(그래프)는 자유도가 높아 더 복잡하다**. + +![연결 리스트, 트리, 그래프 사이의 관계](graph.assets/linkedlist_tree_graph.png){ class="animation-figure" } + +

그림 9-1   연결 리스트, 트리, 그래프 사이의 관계

+ +## 9.1.1   그래프의 주요 종류와 용어 + +간선에 방향이 있는지에 따라 아래 그림(9-2)과 같이 무방향 그래프(undirected graph)와 방향 그래프(directed graph)로 나뉜다. + +- 무방향 그래프에서 간선은 두 정점 사이의 '양방향' 연결 관계를 나타낸다. 위챗이나 QQ의 '친구 관계'가 그 예다. +- 방향 그래프에서 간선은 방향을 가진다. 즉, $A \rightarrow B$와 $A \leftarrow B$ 방향의 간선은 서로 독립적이다. 웨이보나 더우인의 '팔로우'와 '팔로워' 관계가 그 예다. + +![방향 그래프와 무방향 그래프](graph.assets/directed_graph.png){ class="animation-figure" } + +

그림 9-2   방향 그래프와 무방향 그래프

+ +모든 정점이 연결되어 있는지에 따라 아래 그림(9-3)과 같이 연결 그래프(connected graph)와 비연결 그래프(disconnected graph)로 나뉜다. + +- 연결 그래프에서는 한 정점에서 출발하여 다른 모든 정점에 도달할 수 있다. +- 비연결 그래프에서는 한 정점에서 출발했을 때 도달할 수 없는 정점이 하나 이상 존재한다. + +![연결 그래프와 비연결 그래프](graph.assets/connected_graph.png){ class="animation-figure" } + +

그림 9-3   연결 그래프와 비연결 그래프

+ +간선에 '가중치' 변수를 추가하면 아래 그림(9-4)과 같은 가중 그래프(weighted graph)를 얻는다. 예를 들어 『왕자영요』 같은 모바일 게임에서는 함께 게임한 시간을 바탕으로 플레이어 간의 '친밀도'를 계산한다. 이러한 친밀도 네트워크는 가중 그래프로 표현할 수 있다. + +![가중 그래프와 비가중 그래프](graph.assets/weighted_graph.png){ class="animation-figure" } + +

그림 9-4   가중 그래프와 비가중 그래프

+ +그래프 자료 구조에서 자주 사용하는 용어는 다음과 같다. + +- 인접(adjacency): 두 정점 사이에 간선이 있으면 두 정점이 '인접한다'고 한다. 위 그림(9-4)에서 정점 1에 인접한 정점은 2, 3, 5다. +- 경로(path): 정점 A에서 정점 B로 갈 때 거치는 간선의 순서를 A에서 B로 가는 '경로'라고 한다. 위 그림(9-4)에서 간선 순서 1-5-2-4는 정점 1에서 정점 4로 가는 경로다. +- 차수(degree): 한 정점에 연결된 간선의 수다. 방향 그래프에서는 진입 차수(in-degree)가 해당 정점으로 들어오는 간선 수를, 진출 차수(out-degree)가 해당 정점에서 나가는 간선 수를 나타낸다. + +## 9.1.2   그래프의 표현 + +그래프를 표현하는 대표적인 방법은 '인접 행렬'과 '인접 리스트'다. 아래에서는 무방향 그래프를 예로 든다. + +### 1.   인접 행렬 + +그래프의 정점 수를 $n$이라고 하자. 인접 행렬(adjacency matrix)은 $n \times n$ 크기의 행렬로 그래프를 표현한다. 각 행(열)은 정점 하나를, 행렬의 원소는 간선을 나타내며, $1$ 또는 $0$으로 두 정점 사이에 간선이 있는지 표시한다. + +아래 그림(9-5)과 같이 인접 행렬을 $M$, 정점 리스트를 $V$라고 하면, 행렬 원소 $M[i, j] = 1$은 정점 $V[i]$와 정점 $V[j]$ 사이에 간선이 있음을 뜻한다. 반대로 $M[i, j] = 0$은 두 정점 사이에 간선이 없음을 뜻한다. + +![그래프의 인접 행렬 표현](graph.assets/adjacency_matrix.png){ class="animation-figure" } + +

그림 9-5   그래프의 인접 행렬 표현

+ +인접 행렬에는 다음 성질이 있다. + +- 단순 그래프에서는 정점이 자기 자신과 연결될 수 없으므로, 인접 행렬의 주대각선 원소는 의미가 없다. +- 무방향 그래프에서는 두 방향의 간선이 같으므로, 인접 행렬은 주대각선에 대해 대칭이다. +- 인접 행렬의 원소 $1$과 $0$을 가중치로 바꾸면 가중 그래프를 표현할 수 있다. + +인접 행렬로 그래프를 표현하면 행렬 원소에 직접 접근하여 간선을 얻을 수 있다. 따라서 추가, 삭제, 조회, 수정 연산이 매우 효율적이며 시간 복잡도는 모두 $O(1)$이다. 하지만 행렬의 공간 복잡도는 $O(n^2)$이므로 메모리를 많이 차지한다. + +### 2.   인접 리스트 + +인접 리스트(adjacency list)는 $n$ 개의 연결 리스트로 그래프를 표현하며, 연결 리스트의 노드는 정점을 나타낸다. $i$ 번째 연결 리스트는 정점 $i$에 대응하고, 해당 정점에 인접한 모든 정점(간선으로 연결된 정점)을 저장한다. 아래 그림(9-6)은 인접 리스트로 그래프를 저장한 예다. + +![그래프의 인접 리스트 표현](graph.assets/adjacency_list.png){ class="animation-figure" } + +

그림 9-6   그래프의 인접 리스트 표현

+ +인접 리스트는 실제로 존재하는 간선만 저장한다. 전체 간선 수는 보통 $n^2$보다 훨씬 작으므로 공간을 더 절약할 수 있다. 하지만 간선을 찾으려면 연결 리스트를 순회해야 하므로 시간 효율은 인접 행렬보다 낮다. + +위 그림(9-6)을 보면 **인접 리스트의 구조는 해시 테이블의 '체이닝'과 매우 비슷하므로, 비슷한 방법으로 효율을 높일 수 있다**. 예를 들어 연결 리스트가 길면 AVL 트리나 레드-블랙 트리로 바꾸어 시간 복잡도를 $O(n)$에서 $O(\log n)$으로 개선할 수 있다. 연결 리스트를 해시 테이블로 바꾸면 시간 복잡도를 $O(1)$로 줄일 수도 있다. + +## 9.1.3   그래프의 주요 응용 + +아래 표(9-1)와 같이 현실의 많은 시스템을 그래프로 모델링할 수 있으며, 관련 문제를 그래프 계산 문제로 환원할 수 있다. + +

표 9-1   일상에서 볼 수 있는 그래프

+ +
+ +| | 정점 | 간선 | 그래프 계산 문제 | +| -------- | ---- | -------------------- | ------------ | +| 소셜 네트워크 | 사용자 | 친구 관계 | 잠재적 친구 추천 | +| 지하철 노선 | 역 | 역 사이의 연결 관계 | 최단 경로 추천 | +| 태양계 | 천체 | 천체 사이의 만유인력 | 행성 궤도 계산 | + +
diff --git a/ko/docs/chapter_graph/graph_operations.md b/ko/docs/chapter_graph/graph_operations.md new file mode 100644 index 000000000..527567199 --- /dev/null +++ b/ko/docs/chapter_graph/graph_operations.md @@ -0,0 +1,2391 @@ +--- +comments: true +--- + +# 9.2   그래프의 기본 연산 + +그래프의 기본 연산은 '간선'에 대한 연산과 '정점'에 대한 연산으로 나뉜다. '인접 행렬'과 '인접 리스트' 중 어느 표현을 사용하는지에 따라 구현 방법이 달라진다. + +## 9.2.1   인접 행렬 기반 구현 + +정점 수가 $n$인 무방향 그래프가 주어졌을 때 각 연산은 아래 그림(9-7)과 같이 구현한다. + +- **간선 추가 또는 삭제**: 인접 행렬에서 해당 간선을 직접 수정하면 되며, $O(1)$ 시간이 걸린다. 무방향 그래프이므로 두 방향의 간선을 함께 갱신해야 한다. +- **정점 추가**: 인접 행렬의 끝에 행과 열을 하나씩 추가하고 모두 $0$으로 채운다. $O(n)$ 시간이 걸린다. +- **정점 삭제**: 인접 행렬에서 행과 열을 하나씩 삭제한다. 첫 번째 행과 열을 삭제할 때가 최악의 경우로, $(n-1)^2$ 개 원소를 '왼쪽 위로 이동'해야 하므로 $O(n^2)$ 시간이 걸린다. +- **초기화**: 정점 $n$ 개를 전달받아 길이가 $n$인 정점 리스트 `vertices`를 초기화하는 데 $O(n)$ 시간이 걸린다. $n \times n$ 크기의 인접 행렬 `adjMat`을 초기화하는 데는 $O(n^2)$ 시간이 걸린다. + +=== "<1>" + ![인접 행렬의 초기화, 간선 추가와 삭제, 정점 추가와 삭제](graph_operations.assets/adjacency_matrix_step1_initialization.png){ class="animation-figure" } + +=== "<2>" + ![인접 행렬에 간선 추가](graph_operations.assets/adjacency_matrix_step2_add_edge.png){ class="animation-figure" } + +=== "<3>" + ![인접 행렬에서 간선 삭제](graph_operations.assets/adjacency_matrix_step3_remove_edge.png){ class="animation-figure" } + +=== "<4>" + ![인접 행렬에 정점 추가](graph_operations.assets/adjacency_matrix_step4_add_vertex.png){ class="animation-figure" } + +=== "<5>" + ![인접 행렬에서 정점 삭제](graph_operations.assets/adjacency_matrix_step5_remove_vertex.png){ class="animation-figure" } + +

그림 9-7   인접 행렬의 초기화, 간선 추가와 삭제, 정점 추가와 삭제

+ +다음은 인접 행렬로 그래프를 표현한 구현 코드다. + +=== "Python" + + ```python title="graph_adjacency_matrix.py" + class GraphAdjMat: + """인접 행렬로 구현한 무방향 그래프 클래스""" + + def __init__(self, vertices: list[int], edges: list[list[int]]): + """생성자""" + # 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + self.vertices: list[int] = [] + # 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + self.adj_mat: list[list[int]] = [] + # 정점 추가 + for val in vertices: + self.add_vertex(val) + # 간선 추가 + # edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for e in edges: + self.add_edge(e[0], e[1]) + + def size(self) -> int: + """정점 개수 반환""" + return len(self.vertices) + + def add_vertex(self, val: int): + """정점 추가""" + n = self.size() + # 정점 리스트에 새 정점의 값을 추가한다 + self.vertices.append(val) + # 인접 행렬에 행 하나를 추가한다 + new_row = [0] * n + self.adj_mat.append(new_row) + # 인접 행렬에 열 하나를 추가한다 + for row in self.adj_mat: + row.append(0) + + def remove_vertex(self, index: int): + """정점 삭제""" + if index >= self.size(): + raise IndexError() + # 정점 리스트에서 인덱스 index의 정점을 제거한다 + self.vertices.pop(index) + # 인접 행렬에서 인덱스 index의 행을 삭제한다 + self.adj_mat.pop(index) + # 인접 행렬에서 인덱스 index의 열을 삭제한다 + for row in self.adj_mat: + row.pop(index) + + def add_edge(self, i: int, j: int): + """간선 추가""" + # 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + # 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j: + raise IndexError() + # 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + self.adj_mat[i][j] = 1 + self.adj_mat[j][i] = 1 + + def remove_edge(self, i: int, j: int): + """간선 삭제""" + # 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + # 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j: + raise IndexError() + self.adj_mat[i][j] = 0 + self.adj_mat[j][i] = 0 + + def print(self): + """인접 행렬 출력""" + print("정점 리스트 =", self.vertices) + print("인접 행렬 =") + print_matrix(self.adj_mat) + ``` + +=== "C++" + + ```cpp title="graph_adjacency_matrix.cpp" + /* 인접 행렬로 구현한 무방향 그래프 클래스 */ + class GraphAdjMat { + vector vertices; // 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + vector> adjMat; // 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + + public: + /* 생성자 */ + GraphAdjMat(const vector &vertices, const vector> &edges) { + // 정점 추가 + for (int val : vertices) { + addVertex(val); + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for (const vector &edge : edges) { + addEdge(edge[0], edge[1]); + } + } + + /* 정점 개수 반환 */ + int size() const { + return vertices.size(); + } + + /* 정점 추가 */ + void addVertex(int val) { + int n = size(); + // 정점 리스트에 새 정점의 값을 추가한다 + vertices.push_back(val); + // 인접 행렬에 행 하나를 추가한다 + adjMat.emplace_back(vector(n, 0)); + // 인접 행렬에 열 하나를 추가한다 + for (vector &row : adjMat) { + row.push_back(0); + } + } + + /* 정점 삭제 */ + void removeVertex(int index) { + if (index >= size()) { + throw out_of_range("정점이 존재하지 않는다"); + } + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + vertices.erase(vertices.begin() + index); + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + adjMat.erase(adjMat.begin() + index); + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for (vector &row : adjMat) { + row.erase(row.begin() + index); + } + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + void addEdge(int i, int j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) { + throw out_of_range("정점이 존재하지 않는다"); + } + // 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + adjMat[i][j] = 1; + adjMat[j][i] = 1; + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + void removeEdge(int i, int j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) { + throw out_of_range("정점이 존재하지 않는다"); + } + adjMat[i][j] = 0; + adjMat[j][i] = 0; + } + + /* 인접 행렬 출력 */ + void print() { + cout << "정점 리스트 = "; + printVector(vertices); + cout << "인접 행렬 =" << endl; + printVectorMatrix(adjMat); + } + }; + ``` + +=== "Java" + + ```java title="graph_adjacency_matrix.java" + /* 인접 행렬로 구현한 무방향 그래프 클래스 */ + class GraphAdjMat { + List vertices; // 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + List> adjMat; // 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + + /* 생성자 */ + public GraphAdjMat(int[] vertices, int[][] edges) { + this.vertices = new ArrayList<>(); + this.adjMat = new ArrayList<>(); + // 정점 추가 + for (int val : vertices) { + addVertex(val); + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for (int[] e : edges) { + addEdge(e[0], e[1]); + } + } + + /* 정점 개수 반환 */ + public int size() { + return vertices.size(); + } + + /* 정점 추가 */ + public void addVertex(int val) { + int n = size(); + // 정점 리스트에 새 정점의 값을 추가한다 + vertices.add(val); + // 인접 행렬에 행 하나를 추가한다 + List newRow = new ArrayList<>(n); + for (int j = 0; j < n; j++) { + newRow.add(0); + } + adjMat.add(newRow); + // 인접 행렬에 열 하나를 추가한다 + for (List row : adjMat) { + row.add(0); + } + } + + /* 정점 삭제 */ + public void removeVertex(int index) { + if (index >= size()) + throw new IndexOutOfBoundsException(); + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + vertices.remove(index); + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + adjMat.remove(index); + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for (List row : adjMat) { + row.remove(index); + } + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + public void addEdge(int i, int j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) + throw new IndexOutOfBoundsException(); + // 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + adjMat.get(i).set(j, 1); + adjMat.get(j).set(i, 1); + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + public void removeEdge(int i, int j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) + throw new IndexOutOfBoundsException(); + adjMat.get(i).set(j, 0); + adjMat.get(j).set(i, 0); + } + + /* 인접 행렬 출력 */ + public void print() { + System.out.print("정점 리스트 = "); + System.out.println(vertices); + System.out.println("인접 행렬 ="); + PrintUtil.printMatrix(adjMat); + } + } + ``` + +=== "C#" + + ```csharp title="graph_adjacency_matrix.cs" + /* 인접 행렬로 구현한 무방향 그래프 클래스 */ + class GraphAdjMat { + List vertices; // 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + List> adjMat; // 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + + /* 생성자 */ + public GraphAdjMat(int[] vertices, int[][] edges) { + this.vertices = []; + this.adjMat = []; + // 정점 추가 + foreach (int val in vertices) { + AddVertex(val); + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + foreach (int[] e in edges) { + AddEdge(e[0], e[1]); + } + } + + /* 정점 개수 반환 */ + int Size() { + return vertices.Count; + } + + /* 정점 추가 */ + public void AddVertex(int val) { + int n = Size(); + // 정점 리스트에 새 정점의 값을 추가한다 + vertices.Add(val); + // 인접 행렬에 행 하나를 추가한다 + List newRow = new(n); + for (int j = 0; j < n; j++) { + newRow.Add(0); + } + adjMat.Add(newRow); + // 인접 행렬에 열 하나를 추가한다 + foreach (List row in adjMat) { + row.Add(0); + } + } + + /* 정점 삭제 */ + public void RemoveVertex(int index) { + if (index >= Size()) + throw new IndexOutOfRangeException(); + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + vertices.RemoveAt(index); + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + adjMat.RemoveAt(index); + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + foreach (List row in adjMat) { + row.RemoveAt(index); + } + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + public void AddEdge(int i, int j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= Size() || j >= Size() || i == j) + throw new IndexOutOfRangeException(); + // 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + adjMat[i][j] = 1; + adjMat[j][i] = 1; + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + public void RemoveEdge(int i, int j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= Size() || j >= Size() || i == j) + throw new IndexOutOfRangeException(); + adjMat[i][j] = 0; + adjMat[j][i] = 0; + } + + /* 인접 행렬 출력 */ + public void Print() { + Console.Write("정점 리스트 = "); + PrintUtil.PrintList(vertices); + Console.WriteLine("인접 행렬 ="); + PrintUtil.PrintMatrix(adjMat); + } + } + ``` + +=== "Go" + + ```go title="graph_adjacency_matrix.go" + /* 인접 행렬로 구현한 무방향 그래프 클래스 */ + type graphAdjMat struct { + // 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + vertices []int + // 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + adjMat [][]int + } + + /* 생성자 */ + func newGraphAdjMat(vertices []int, edges [][]int) *graphAdjMat { + // 정점 추가 + n := len(vertices) + adjMat := make([][]int, n) + for i := range adjMat { + adjMat[i] = make([]int, n) + } + // 그래프 초기화 + g := &graphAdjMat{ + vertices: vertices, + adjMat: adjMat, + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for i := range edges { + g.addEdge(edges[i][0], edges[i][1]) + } + return g + } + + /* 정점 개수 반환 */ + func (g *graphAdjMat) size() int { + return len(g.vertices) + } + + /* 정점 추가 */ + func (g *graphAdjMat) addVertex(val int) { + n := g.size() + // 정점 리스트에 새 정점의 값을 추가한다 + g.vertices = append(g.vertices, val) + // 인접 행렬에 행 하나를 추가한다 + newRow := make([]int, n) + g.adjMat = append(g.adjMat, newRow) + // 인접 행렬에 열 하나를 추가한다 + for i := range g.adjMat { + g.adjMat[i] = append(g.adjMat[i], 0) + } + } + + /* 정점 삭제 */ + func (g *graphAdjMat) removeVertex(index int) { + if index >= g.size() { + return + } + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + g.vertices = append(g.vertices[:index], g.vertices[index+1:]...) + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + g.adjMat = append(g.adjMat[:index], g.adjMat[index+1:]...) + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for i := range g.adjMat { + g.adjMat[i] = append(g.adjMat[i][:index], g.adjMat[i][index+1:]...) + } + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + func (g *graphAdjMat) addEdge(i, j int) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i < 0 || j < 0 || i >= g.size() || j >= g.size() || i == j { + fmt.Errorf("%s", "인덱스가 범위를 벗어났다") + } + // 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + g.adjMat[i][j] = 1 + g.adjMat[j][i] = 1 + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + func (g *graphAdjMat) removeEdge(i, j int) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i < 0 || j < 0 || i >= g.size() || j >= g.size() || i == j { + fmt.Errorf("%s", "인덱스가 범위를 벗어났다") + } + g.adjMat[i][j] = 0 + g.adjMat[j][i] = 0 + } + + /* 인접 행렬 출력 */ + func (g *graphAdjMat) print() { + fmt.Printf("\t정점 리스트 = %v\n", g.vertices) + fmt.Printf("\t인접 행렬 = \n") + for i := range g.adjMat { + fmt.Printf("\t\t\t%v\n", g.adjMat[i]) + } + } + ``` + +=== "Swift" + + ```swift title="graph_adjacency_matrix.swift" + /* 인접 행렬로 구현한 무방향 그래프 클래스 */ + class GraphAdjMat { + private var vertices: [Int] // 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + private var adjMat: [[Int]] // 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + + /* 생성자 */ + init(vertices: [Int], edges: [[Int]]) { + self.vertices = [] + adjMat = [] + // 정점 추가 + for val in vertices { + addVertex(val: val) + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for e in edges { + addEdge(i: e[0], j: e[1]) + } + } + + /* 정점 개수 반환 */ + func size() -> Int { + vertices.count + } + + /* 정점 추가 */ + func addVertex(val: Int) { + let n = size() + // 정점 리스트에 새 정점의 값을 추가한다 + vertices.append(val) + // 인접 행렬에 행 하나를 추가한다 + let newRow = Array(repeating: 0, count: n) + adjMat.append(newRow) + // 인접 행렬에 열 하나를 추가한다 + for i in adjMat.indices { + adjMat[i].append(0) + } + } + + /* 정점 삭제 */ + func removeVertex(index: Int) { + if index >= size() { + fatalError("범위를 벗어남") + } + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + vertices.remove(at: index) + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + adjMat.remove(at: index) + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for i in adjMat.indices { + adjMat[i].remove(at: index) + } + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + func addEdge(i: Int, j: Int) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i < 0 || j < 0 || i >= size() || j >= size() || i == j { + fatalError("범위를 벗어남") + } + // 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + adjMat[i][j] = 1 + adjMat[j][i] = 1 + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + func removeEdge(i: Int, j: Int) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i < 0 || j < 0 || i >= size() || j >= size() || i == j { + fatalError("범위를 벗어남") + } + adjMat[i][j] = 0 + adjMat[j][i] = 0 + } + + /* 인접 행렬 출력 */ + func print() { + Swift.print("정점 리스트 = ", terminator: "") + Swift.print(vertices) + Swift.print("인접 행렬 =") + PrintUtil.printMatrix(matrix: adjMat) + } + } + ``` + +=== "JS" + + ```javascript title="graph_adjacency_matrix.js" + /* 인접 행렬로 구현한 무방향 그래프 클래스 */ + class GraphAdjMat { + vertices; // 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + adjMat; // 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + + /* 생성자 */ + constructor(vertices, edges) { + this.vertices = []; + this.adjMat = []; + // 정점 추가 + for (const val of vertices) { + this.addVertex(val); + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for (const e of edges) { + this.addEdge(e[0], e[1]); + } + } + + /* 정점 개수 반환 */ + size() { + return this.vertices.length; + } + + /* 정점 추가 */ + addVertex(val) { + const n = this.size(); + // 정점 리스트에 새 정점의 값을 추가한다 + this.vertices.push(val); + // 인접 행렬에 행 하나를 추가한다 + const newRow = []; + for (let j = 0; j < n; j++) { + newRow.push(0); + } + this.adjMat.push(newRow); + // 인접 행렬에 열 하나를 추가한다 + for (const row of this.adjMat) { + row.push(0); + } + } + + /* 정점 삭제 */ + removeVertex(index) { + if (index >= this.size()) { + throw new RangeError('인덱스가 범위를 벗어났다'); + } + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + this.vertices.splice(index, 1); + + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + this.adjMat.splice(index, 1); + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for (const row of this.adjMat) { + row.splice(index, 1); + } + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + addEdge(i, j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= this.size() || j >= this.size() || i === j) { + throw new RangeError('인덱스가 범위를 벗어났다'); + } + // 무방향 그래프의 인접 행렬은 주대각선을 기준으로 대칭이므로 (i, j) === (j, i)를 만족한다 + this.adjMat[i][j] = 1; + this.adjMat[j][i] = 1; + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + removeEdge(i, j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= this.size() || j >= this.size() || i === j) { + throw new RangeError('인덱스가 범위를 벗어났다'); + } + this.adjMat[i][j] = 0; + this.adjMat[j][i] = 0; + } + + /* 인접 행렬 출력 */ + print() { + console.log('정점 리스트 = ', this.vertices); + console.log('인접 행렬 =', this.adjMat); + } + } + ``` + +=== "TS" + + ```typescript title="graph_adjacency_matrix.ts" + /* 인접 행렬로 구현한 무방향 그래프 클래스 */ + class GraphAdjMat { + vertices: number[]; // 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + adjMat: number[][]; // 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + + /* 생성자 */ + constructor(vertices: number[], edges: number[][]) { + this.vertices = []; + this.adjMat = []; + // 정점 추가 + for (const val of vertices) { + this.addVertex(val); + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for (const e of edges) { + this.addEdge(e[0], e[1]); + } + } + + /* 정점 개수 반환 */ + size(): number { + return this.vertices.length; + } + + /* 정점 추가 */ + addVertex(val: number): void { + const n: number = this.size(); + // 정점 리스트에 새 정점의 값을 추가한다 + this.vertices.push(val); + // 인접 행렬에 행 하나를 추가한다 + const newRow: number[] = []; + for (let j: number = 0; j < n; j++) { + newRow.push(0); + } + this.adjMat.push(newRow); + // 인접 행렬에 열 하나를 추가한다 + for (const row of this.adjMat) { + row.push(0); + } + } + + /* 정점 삭제 */ + removeVertex(index: number): void { + if (index >= this.size()) { + throw new RangeError('인덱스가 범위를 벗어났다'); + } + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + this.vertices.splice(index, 1); + + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + this.adjMat.splice(index, 1); + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for (const row of this.adjMat) { + row.splice(index, 1); + } + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + addEdge(i: number, j: number): void { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= this.size() || j >= this.size() || i === j) { + throw new RangeError('인덱스가 범위를 벗어났다'); + } + // 무방향 그래프의 인접 행렬은 주대각선을 기준으로 대칭이므로 (i, j) === (j, i)를 만족한다 + this.adjMat[i][j] = 1; + this.adjMat[j][i] = 1; + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + removeEdge(i: number, j: number): void { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= this.size() || j >= this.size() || i === j) { + throw new RangeError('인덱스가 범위를 벗어났다'); + } + this.adjMat[i][j] = 0; + this.adjMat[j][i] = 0; + } + + /* 인접 행렬 출력 */ + print(): void { + console.log('정점 리스트 = ', this.vertices); + console.log('인접 행렬 =', this.adjMat); + } + } + ``` + +=== "Dart" + + ```dart title="graph_adjacency_matrix.dart" + /* 인접 행렬로 구현한 무방향 그래프 클래스 */ + class GraphAdjMat { + List vertices = []; // 정점 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + List> adjMat = []; //인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + + /* 생성자 */ + GraphAdjMat(List vertices, List> edges) { + this.vertices = []; + this.adjMat = []; + // 정점 추가 + for (int val in vertices) { + addVertex(val); + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for (List e in edges) { + addEdge(e[0], e[1]); + } + } + + /* 정점 개수 반환 */ + int size() { + return vertices.length; + } + + /* 정점 추가 */ + void addVertex(int val) { + int n = size(); + // 정점 리스트에 새 정점의 값을 추가한다 + vertices.add(val); + // 인접 행렬에 행 하나를 추가한다 + List newRow = List.filled(n, 0, growable: true); + adjMat.add(newRow); + // 인접 행렬에 열 하나를 추가한다 + for (List row in adjMat) { + row.add(0); + } + } + + /* 정점 삭제 */ + void removeVertex(int index) { + if (index >= size()) { + throw IndexError; + } + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + vertices.removeAt(index); + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + adjMat.removeAt(index); + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for (List row in adjMat) { + row.removeAt(index); + } + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + void addEdge(int i, int j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) { + throw IndexError; + } + // 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + adjMat[i][j] = 1; + adjMat[j][i] = 1; + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + void removeEdge(int i, int j) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) { + throw IndexError; + } + adjMat[i][j] = 0; + adjMat[j][i] = 0; + } + + /* 인접 행렬 출력 */ + void printAdjMat() { + print("정점 리스트 = $vertices"); + print("인접 행렬 = "); + printMatrix(adjMat); + } + } + ``` + +=== "Rust" + + ```rust title="graph_adjacency_matrix.rs" + /* 인접 행렬로 구현한 무방향 그래프 자료형 */ + pub struct GraphAdjMat { + // 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + pub vertices: Vec, + // 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + pub adj_mat: Vec>, + } + + impl GraphAdjMat { + /* 생성자 */ + pub fn new(vertices: Vec, edges: Vec<[usize; 2]>) -> Self { + let mut graph = GraphAdjMat { + vertices: vec![], + adj_mat: vec![], + }; + // 정점 추가 + for val in vertices { + graph.add_vertex(val); + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for edge in edges { + graph.add_edge(edge[0], edge[1]) + } + + graph + } + + /* 정점 개수 반환 */ + pub fn size(&self) -> usize { + self.vertices.len() + } + + /* 정점 추가 */ + pub fn add_vertex(&mut self, val: i32) { + let n = self.size(); + // 정점 리스트에 새 정점의 값을 추가한다 + self.vertices.push(val); + // 인접 행렬에 행 하나를 추가한다 + self.adj_mat.push(vec![0; n]); + // 인접 행렬에 열 하나를 추가한다 + for row in self.adj_mat.iter_mut() { + row.push(0); + } + } + + /* 정점 삭제 */ + pub fn remove_vertex(&mut self, index: usize) { + if index >= self.size() { + panic!("인덱스 오류") + } + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + self.vertices.remove(index); + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + self.adj_mat.remove(index); + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for row in self.adj_mat.iter_mut() { + row.remove(index); + } + } + + /* 간선 추가 */ + pub fn add_edge(&mut self, i: usize, j: usize) { + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i >= self.size() || j >= self.size() || i == j { + panic!("인덱스 오류") + } + // 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + self.adj_mat[i][j] = 1; + self.adj_mat[j][i] = 1; + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + pub fn remove_edge(&mut self, i: usize, j: usize) { + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i >= self.size() || j >= self.size() || i == j { + panic!("인덱스 오류") + } + self.adj_mat[i][j] = 0; + self.adj_mat[j][i] = 0; + } + + /* 인접 행렬 출력 */ + pub fn print(&self) { + println!("정점 리스트 = {:?}", self.vertices); + println!("인접 행렬 ="); + println!("["); + for row in &self.adj_mat { + println!(" {:?},", row); + } + println!("]") + } + } + ``` + +=== "C" + + ```c title="graph_adjacency_matrix.c" + /* 인접 행렬로 구현한 무방향 그래프 구조체 */ + typedef struct { + int vertices[MAX_SIZE]; + int adjMat[MAX_SIZE][MAX_SIZE]; + int size; + } GraphAdjMat; + + /* 생성자 */ + GraphAdjMat *newGraphAdjMat() { + GraphAdjMat *graph = (GraphAdjMat *)malloc(sizeof(GraphAdjMat)); + graph->size = 0; + for (int i = 0; i < MAX_SIZE; i++) { + for (int j = 0; j < MAX_SIZE; j++) { + graph->adjMat[i][j] = 0; + } + } + return graph; + } + + /* 소멸자 */ + void delGraphAdjMat(GraphAdjMat *graph) { + free(graph); + } + + /* 정점 추가 */ + void addVertex(GraphAdjMat *graph, int val) { + if (graph->size == MAX_SIZE) { + fprintf(stderr, "그래프의 정점 개수가 최댓값에 도달했다\n"); + return; + } + // n번째 정점을 추가하고 n번째 행과 열을 0으로 설정한다 + int n = graph->size; + graph->vertices[n] = val; + for (int i = 0; i <= n; i++) { + graph->adjMat[n][i] = graph->adjMat[i][n] = 0; + } + graph->size++; + } + + /* 정점 삭제 */ + void removeVertex(GraphAdjMat *graph, int index) { + if (index < 0 || index >= graph->size) { + fprintf(stderr, "정점 인덱스가 범위를 벗어났다\n"); + return; + } + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + for (int i = index; i < graph->size - 1; i++) { + graph->vertices[i] = graph->vertices[i + 1]; + } + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + for (int i = index; i < graph->size - 1; i++) { + for (int j = 0; j < graph->size; j++) { + graph->adjMat[i][j] = graph->adjMat[i + 1][j]; + } + } + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for (int i = 0; i < graph->size; i++) { + for (int j = index; j < graph->size - 1; j++) { + graph->adjMat[i][j] = graph->adjMat[i][j + 1]; + } + } + graph->size--; + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + void addEdge(GraphAdjMat *graph, int i, int j) { + if (i < 0 || j < 0 || i >= graph->size || j >= graph->size || i == j) { + fprintf(stderr, "간선 인덱스가 범위를 벗어나거나 서로 같다\n"); + return; + } + graph->adjMat[i][j] = 1; + graph->adjMat[j][i] = 1; + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + void removeEdge(GraphAdjMat *graph, int i, int j) { + if (i < 0 || j < 0 || i >= graph->size || j >= graph->size || i == j) { + fprintf(stderr, "간선 인덱스가 범위를 벗어나거나 서로 같다\n"); + return; + } + graph->adjMat[i][j] = 0; + graph->adjMat[j][i] = 0; + } + + /* 인접 행렬 출력 */ + void printGraphAdjMat(GraphAdjMat *graph) { + printf("정점 리스트 = "); + printArray(graph->vertices, graph->size); + printf("인접 행렬 =\n"); + for (int i = 0; i < graph->size; i++) { + printArray(graph->adjMat[i], graph->size); + } + } + ``` + +=== "Kotlin" + + ```kotlin title="graph_adjacency_matrix.kt" + /* 인접 행렬로 구현한 무방향 그래프 클래스 */ + class GraphAdjMat(vertices: IntArray, edges: Array) { + val vertices = mutableListOf() // 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + val adjMat = mutableListOf>() // 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + + /* 생성자 */ + init { + // 정점 추가 + for (vertex in vertices) { + addVertex(vertex) + } + // 간선 추가 + // edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + for (edge in edges) { + addEdge(edge[0], edge[1]) + } + } + + /* 정점 개수 반환 */ + fun size(): Int { + return vertices.size + } + + /* 정점 추가 */ + fun addVertex(_val: Int) { + val n = size() + // 정점 리스트에 새 정점의 값을 추가한다 + vertices.add(_val) + // 인접 행렬에 행 하나를 추가한다 + val newRow = mutableListOf() + for (j in 0..= size()) + throw IndexOutOfBoundsException() + // 정점 리스트에서 인덱스 index의 정점을 제거한다 + vertices.removeAt(index) + // 인접 행렬에서 인덱스 index의 행을 삭제한다 + adjMat.removeAt(index) + // 인접 행렬에서 인덱스 index의 열을 삭제한다 + for (row in adjMat) { + row.removeAt(index) + } + } + + /* 간선 추가 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + fun addEdge(i: Int, j: Int) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) + throw IndexOutOfBoundsException() + // 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + adjMat[i][j] = 1 + adjMat[j][i] = 1 + } + + /* 간선 삭제 */ + // 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + fun removeEdge(i: Int, j: Int) { + // 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) + throw IndexOutOfBoundsException() + adjMat[i][j] = 0 + adjMat[j][i] = 0 + } + + /* 인접 행렬 출력 */ + fun print() { + print("정점 리스트 = ") + println(vertices) + println("인접 행렬 =") + printMatrix(adjMat) + } + } + ``` + +=== "Ruby" + + ```ruby title="graph_adjacency_matrix.rb" + ### 인접 행렬로 구현한 무방향 그래프 클래스 ### + class GraphAdjMat + def initialize(vertices, edges) + ### 생성자 ### + # 정점 리스트의 원소는 정점 값을, 인덱스는 정점 인덱스를 나타낸다 + @vertices = [] + # 인접 행렬의 행과 열 인덱스는 정점 인덱스에 해당한다 + @adj_mat = [] + # 정점 추가 + vertices.each { |val| add_vertex(val) } + # 간선 추가 + # edges의 원소는 정점 인덱스, 즉 vertices 원소의 인덱스를 나타낸다 + edges.each { |e| add_edge(e[0], e[1]) } + end + + ### 정점 개수 반환 ### + def size + @vertices.length + end + + ### 정점 추가 ### + def add_vertex(val) + n = size + # 정점 리스트에 새 정점의 값을 추가한다 + @vertices << val + # 인접 행렬에 행 하나를 추가한다 + new_row = Array.new(n, 0) + @adj_mat << new_row + # 인접 행렬에 열 하나를 추가한다 + @adj_mat.each { |row| row << 0 } + end + + ### 정점 삭제 ### + def remove_vertex(index) + raise IndexError if index >= size + + # 정점 리스트에서 인덱스 index의 정점을 제거한다 + @vertices.delete_at(index) + # 인접 행렬에서 인덱스 index의 행을 삭제한다 + @adj_mat.delete_at(index) + # 인접 행렬에서 인덱스 index의 열을 삭제한다 + @adj_mat.each { |row| row.delete_at(index) } + end + + ### 간선 추가 ### + def add_edge(i, j) + # 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + # 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i < 0 || j < 0 || i >= size || j >= size || i == j + raise IndexError + end + # 무방향 그래프의 인접 행렬은 주대각선에 대해 대칭이며, (i, j) == (j, i)를 만족한다 + @adj_mat[i][j] = 1 + @adj_mat[j][i] = 1 + end + + ### 간선 삭제 ### + def remove_edge(i, j) + # 매개변수 i, j는 vertices 원소의 인덱스에 해당한다 + # 인덱스가 범위를 벗어나거나 서로 같은 경우를 처리한다 + if i < 0 || j < 0 || i >= size || j >= size || i == j + raise IndexError + end + @adj_mat[i][j] = 0 + @adj_mat[j][i] = 0 + end + + ### 인접 행렬 출력 ### + def __print__ + puts "정점 리스트 = #{@vertices}" + puts '인접 행렬 =' + print_matrix(@adj_mat) + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 9.2.2   인접 리스트 기반 구현 + +무방향 그래프의 전체 정점 수가 $n$, 전체 간선 수가 $m$이라면 아래 그림(9-8)의 방법으로 각 연산을 구현할 수 있다. + +- **간선 추가**: 해당 정점에 대응하는 연결 리스트 끝에 간선을 추가한다. $O(1)$ 시간이 걸린다. 무방향 그래프이므로 두 방향의 간선을 함께 추가해야 한다. +- **간선 삭제**: 해당 정점에 대응하는 연결 리스트에서 지정한 간선을 찾아 삭제한다. $O(m)$ 시간이 걸린다. 무방향 그래프에서는 두 방향의 간선을 함께 삭제해야 한다. +- **정점 추가**: 인접 리스트에 연결 리스트 하나를 추가하고 새 정점을 그 연결 리스트의 머리 노드로 둔다. $O(1)$ 시간이 걸린다. +- **정점 삭제**: 인접 리스트 전체를 순회하며 지정한 정점이 포함된 모든 간선을 삭제한다. $O(n + m)$ 시간이 걸린다. +- **초기화**: 인접 리스트에 정점 $n$ 개와 간선 $2m$ 개를 만든다. $O(n + m)$ 시간이 걸린다. + +=== "<1>" + ![인접 리스트의 초기화, 간선 추가와 삭제, 정점 추가와 삭제](graph_operations.assets/adjacency_list_step1_initialization.png){ class="animation-figure" } + +=== "<2>" + ![인접 리스트에 간선 추가](graph_operations.assets/adjacency_list_step2_add_edge.png){ class="animation-figure" } + +=== "<3>" + ![인접 리스트에서 간선 삭제](graph_operations.assets/adjacency_list_step3_remove_edge.png){ class="animation-figure" } + +=== "<4>" + ![인접 리스트에 정점 추가](graph_operations.assets/adjacency_list_step4_add_vertex.png){ class="animation-figure" } + +=== "<5>" + ![인접 리스트에서 정점 삭제](graph_operations.assets/adjacency_list_step5_remove_vertex.png){ class="animation-figure" } + +

그림 9-8   인접 리스트의 초기화, 간선 추가와 삭제, 정점 추가와 삭제

+ +다음은 인접 리스트의 구현 코드다. 위 그림(9-8)과 비교하면 실제 코드에는 다음 차이가 있다. + +- 정점을 편리하게 추가하고 삭제하며 코드를 간단하게 만들기 위해 연결 리스트 대신 리스트(동적 배열)를 사용한다. +- 해시 테이블로 인접 리스트를 저장한다. `key`는 정점 인스턴스이고, `value`는 그 정점의 인접 정점 리스트(연결 리스트)다. + +또한 인접 리스트에서 정점을 나타내기 위해 `Vertex` 클래스를 사용한다. 인접 행렬처럼 리스트 인덱스로 정점을 구분한다면 인덱스가 $i$인 정점을 삭제할 때 인접 리스트 전체를 순회하면서 $i$보다 큰 모든 인덱스를 $1$씩 줄여야 하므로 매우 비효율적이기 때문이다. 반면 정점마다 고유한 `Vertex` 인스턴스를 사용하면 어느 정점을 삭제해도 다른 정점을 수정할 필요가 없다. + +=== "Python" + + ```python title="graph_adjacency_list.py" + class GraphAdjList: + """인접 리스트로 구현한 무방향 그래프 클래스""" + + def __init__(self, edges: list[list[Vertex]]): + """생성자""" + # 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + self.adj_list = dict[Vertex, list[Vertex]]() + # 모든 정점과 간선을 추가한다 + for edge in edges: + self.add_vertex(edge[0]) + self.add_vertex(edge[1]) + self.add_edge(edge[0], edge[1]) + + def size(self) -> int: + """정점 개수 반환""" + return len(self.adj_list) + + def add_edge(self, vet1: Vertex, vet2: Vertex): + """간선 추가""" + if vet1 not in self.adj_list or vet2 not in self.adj_list or vet1 == vet2: + raise ValueError() + # 간선 vet1 - vet2를 추가한다 + self.adj_list[vet1].append(vet2) + self.adj_list[vet2].append(vet1) + + def remove_edge(self, vet1: Vertex, vet2: Vertex): + """간선 삭제""" + if vet1 not in self.adj_list or vet2 not in self.adj_list or vet1 == vet2: + raise ValueError() + # 간선 vet1 - vet2를 삭제한다 + self.adj_list[vet1].remove(vet2) + self.adj_list[vet2].remove(vet1) + + def add_vertex(self, vet: Vertex): + """정점 추가""" + if vet in self.adj_list: + return + # 인접 리스트에 새 연결 리스트를 추가한다 + self.adj_list[vet] = [] + + def remove_vertex(self, vet: Vertex): + """정점 삭제""" + if vet not in self.adj_list: + raise ValueError() + # 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + self.adj_list.pop(vet) + # 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for vertex in self.adj_list: + if vet in self.adj_list[vertex]: + self.adj_list[vertex].remove(vet) + + def print(self): + """인접 리스트 출력""" + print("인접 리스트 =") + for vertex in self.adj_list: + tmp = [v.val for v in self.adj_list[vertex]] + print(f"{vertex.val}: {tmp},") + ``` + +=== "C++" + + ```cpp title="graph_adjacency_list.cpp" + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + class GraphAdjList { + public: + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + unordered_map> adjList; + + /* vector에서 지정한 노드 삭제 */ + void remove(vector &vec, Vertex *vet) { + for (int i = 0; i < vec.size(); i++) { + if (vec[i] == vet) { + vec.erase(vec.begin() + i); + break; + } + } + } + + /* 생성자 */ + GraphAdjList(const vector> &edges) { + // 모든 정점과 간선을 추가한다 + for (const vector &edge : edges) { + addVertex(edge[0]); + addVertex(edge[1]); + addEdge(edge[0], edge[1]); + } + } + + /* 정점 개수 반환 */ + int size() { + return adjList.size(); + } + + /* 간선 추가 */ + void addEdge(Vertex *vet1, Vertex *vet2) { + if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2) + throw invalid_argument("정점이 존재하지 않는다"); + // 간선 vet1 - vet2를 추가한다 + adjList[vet1].push_back(vet2); + adjList[vet2].push_back(vet1); + } + + /* 간선 삭제 */ + void removeEdge(Vertex *vet1, Vertex *vet2) { + if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2) + throw invalid_argument("정점이 존재하지 않는다"); + // 간선 vet1 - vet2를 삭제한다 + remove(adjList[vet1], vet2); + remove(adjList[vet2], vet1); + } + + /* 정점 추가 */ + void addVertex(Vertex *vet) { + if (adjList.count(vet)) + return; + // 인접 리스트에 새 연결 리스트를 추가한다 + adjList[vet] = vector(); + } + + /* 정점 삭제 */ + void removeVertex(Vertex *vet) { + if (!adjList.count(vet)) + throw invalid_argument("정점이 존재하지 않는다"); + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + adjList.erase(vet); + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for (auto &adj : adjList) { + remove(adj.second, vet); + } + } + + /* 인접 리스트 출력 */ + void print() { + cout << "인접 리스트 =" << endl; + for (auto &adj : adjList) { + const auto &key = adj.first; + const auto &vec = adj.second; + cout << key->val << ": "; + printVector(vetsToVals(vec)); + } + } + }; + ``` + +=== "Java" + + ```java title="graph_adjacency_list.java" + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + class GraphAdjList { + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + Map> adjList; + + /* 생성자 */ + public GraphAdjList(Vertex[][] edges) { + this.adjList = new HashMap<>(); + // 모든 정점과 간선을 추가한다 + for (Vertex[] edge : edges) { + addVertex(edge[0]); + addVertex(edge[1]); + addEdge(edge[0], edge[1]); + } + } + + /* 정점 개수 반환 */ + public int size() { + return adjList.size(); + } + + /* 간선 추가 */ + public void addEdge(Vertex vet1, Vertex vet2) { + if (!adjList.containsKey(vet1) || !adjList.containsKey(vet2) || vet1 == vet2) + throw new IllegalArgumentException(); + // 간선 vet1 - vet2를 추가한다 + adjList.get(vet1).add(vet2); + adjList.get(vet2).add(vet1); + } + + /* 간선 삭제 */ + public void removeEdge(Vertex vet1, Vertex vet2) { + if (!adjList.containsKey(vet1) || !adjList.containsKey(vet2) || vet1 == vet2) + throw new IllegalArgumentException(); + // 간선 vet1 - vet2를 삭제한다 + adjList.get(vet1).remove(vet2); + adjList.get(vet2).remove(vet1); + } + + /* 정점 추가 */ + public void addVertex(Vertex vet) { + if (adjList.containsKey(vet)) + return; + // 인접 리스트에 새 연결 리스트를 추가한다 + adjList.put(vet, new ArrayList<>()); + } + + /* 정점 삭제 */ + public void removeVertex(Vertex vet) { + if (!adjList.containsKey(vet)) + throw new IllegalArgumentException(); + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + adjList.remove(vet); + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for (List list : adjList.values()) { + list.remove(vet); + } + } + + /* 인접 리스트 출력 */ + public void print() { + System.out.println("인접 리스트 ="); + for (Map.Entry> pair : adjList.entrySet()) { + List tmp = new ArrayList<>(); + for (Vertex vertex : pair.getValue()) + tmp.add(vertex.val); + System.out.println(pair.getKey().val + ": " + tmp + ","); + } + } + } + ``` + +=== "C#" + + ```csharp title="graph_adjacency_list.cs" + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + class GraphAdjList { + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + public Dictionary> adjList; + + /* 생성자 */ + public GraphAdjList(Vertex[][] edges) { + adjList = []; + // 모든 정점과 간선을 추가한다 + foreach (Vertex[] edge in edges) { + AddVertex(edge[0]); + AddVertex(edge[1]); + AddEdge(edge[0], edge[1]); + } + } + + /* 정점 개수 반환 */ + int Size() { + return adjList.Count; + } + + /* 간선 추가 */ + public void AddEdge(Vertex vet1, Vertex vet2) { + if (!adjList.ContainsKey(vet1) || !adjList.ContainsKey(vet2) || vet1 == vet2) + throw new InvalidOperationException(); + // 간선 vet1 - vet2를 추가한다 + adjList[vet1].Add(vet2); + adjList[vet2].Add(vet1); + } + + /* 간선 삭제 */ + public void RemoveEdge(Vertex vet1, Vertex vet2) { + if (!adjList.ContainsKey(vet1) || !adjList.ContainsKey(vet2) || vet1 == vet2) + throw new InvalidOperationException(); + // 간선 vet1 - vet2를 삭제한다 + adjList[vet1].Remove(vet2); + adjList[vet2].Remove(vet1); + } + + /* 정점 추가 */ + public void AddVertex(Vertex vet) { + if (adjList.ContainsKey(vet)) + return; + // 인접 리스트에 새 연결 리스트를 추가한다 + adjList.Add(vet, []); + } + + /* 정점 삭제 */ + public void RemoveVertex(Vertex vet) { + if (!adjList.ContainsKey(vet)) + throw new InvalidOperationException(); + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + adjList.Remove(vet); + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + foreach (List list in adjList.Values) { + list.Remove(vet); + } + } + + /* 인접 리스트 출력 */ + public void Print() { + Console.WriteLine("인접 리스트 ="); + foreach (KeyValuePair> pair in adjList) { + List tmp = []; + foreach (Vertex vertex in pair.Value) + tmp.Add(vertex.val); + Console.WriteLine(pair.Key.val + ": [" + string.Join(", ", tmp) + "],"); + } + } + } + ``` + +=== "Go" + + ```go title="graph_adjacency_list.go" + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + type graphAdjList struct { + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + adjList map[Vertex][]Vertex + } + + /* 생성자 */ + func newGraphAdjList(edges [][]Vertex) *graphAdjList { + g := &graphAdjList{ + adjList: make(map[Vertex][]Vertex), + } + // 모든 정점과 간선을 추가한다 + for _, edge := range edges { + g.addVertex(edge[0]) + g.addVertex(edge[1]) + g.addEdge(edge[0], edge[1]) + } + return g + } + + /* 정점 개수 반환 */ + func (g *graphAdjList) size() int { + return len(g.adjList) + } + + /* 간선 추가 */ + func (g *graphAdjList) addEdge(vet1 Vertex, vet2 Vertex) { + _, ok1 := g.adjList[vet1] + _, ok2 := g.adjList[vet2] + if !ok1 || !ok2 || vet1 == vet2 { + panic("오류") + } + // 간선 vet1 - vet2를 추가하고 익명 struct{}를 추가한다 + g.adjList[vet1] = append(g.adjList[vet1], vet2) + g.adjList[vet2] = append(g.adjList[vet2], vet1) + } + + /* 간선 삭제 */ + func (g *graphAdjList) removeEdge(vet1 Vertex, vet2 Vertex) { + _, ok1 := g.adjList[vet1] + _, ok2 := g.adjList[vet2] + if !ok1 || !ok2 || vet1 == vet2 { + panic("오류") + } + // 간선 vet1 - vet2를 삭제한다 + g.adjList[vet1] = DeleteSliceElms(g.adjList[vet1], vet2) + g.adjList[vet2] = DeleteSliceElms(g.adjList[vet2], vet1) + } + + /* 정점 추가 */ + func (g *graphAdjList) addVertex(vet Vertex) { + _, ok := g.adjList[vet] + if ok { + return + } + // 인접 리스트에 새 연결 리스트를 추가한다 + g.adjList[vet] = make([]Vertex, 0) + } + + /* 정점 삭제 */ + func (g *graphAdjList) removeVertex(vet Vertex) { + _, ok := g.adjList[vet] + if !ok { + panic("오류") + } + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + delete(g.adjList, vet) + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for v, list := range g.adjList { + g.adjList[v] = DeleteSliceElms(list, vet) + } + } + + /* 인접 리스트 출력 */ + func (g *graphAdjList) print() { + var builder strings.Builder + fmt.Printf("인접 리스트 = \n") + for k, v := range g.adjList { + builder.WriteString("\t\t" + strconv.Itoa(k.Val) + ": ") + for _, vet := range v { + builder.WriteString(strconv.Itoa(vet.Val) + " ") + } + fmt.Println(builder.String()) + builder.Reset() + } + } + ``` + +=== "Swift" + + ```swift title="graph_adjacency_list.swift" + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + class GraphAdjList { + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + public private(set) var adjList: [Vertex: [Vertex]] + + /* 생성자 */ + public init(edges: [[Vertex]]) { + adjList = [:] + // 모든 정점과 간선을 추가한다 + for edge in edges { + addVertex(vet: edge[0]) + addVertex(vet: edge[1]) + addEdge(vet1: edge[0], vet2: edge[1]) + } + } + + /* 정점 개수 반환 */ + public func size() -> Int { + adjList.count + } + + /* 간선 추가 */ + public func addEdge(vet1: Vertex, vet2: Vertex) { + if adjList[vet1] == nil || adjList[vet2] == nil || vet1 == vet2 { + fatalError("인수 오류") + } + // 간선 vet1 - vet2를 추가한다 + adjList[vet1]?.append(vet2) + adjList[vet2]?.append(vet1) + } + + /* 간선 삭제 */ + public func removeEdge(vet1: Vertex, vet2: Vertex) { + if adjList[vet1] == nil || adjList[vet2] == nil || vet1 == vet2 { + fatalError("인수 오류") + } + // 간선 vet1 - vet2를 삭제한다 + adjList[vet1]?.removeAll { $0 == vet2 } + adjList[vet2]?.removeAll { $0 == vet1 } + } + + /* 정점 추가 */ + public func addVertex(vet: Vertex) { + if adjList[vet] != nil { + return + } + // 인접 리스트에 새 연결 리스트를 추가한다 + adjList[vet] = [] + } + + /* 정점 삭제 */ + public func removeVertex(vet: Vertex) { + if adjList[vet] == nil { + fatalError("인수 오류") + } + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + adjList.removeValue(forKey: vet) + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for key in adjList.keys { + adjList[key]?.removeAll { $0 == vet } + } + } + + /* 인접 리스트 출력 */ + public func print() { + Swift.print("인접 리스트 =") + for (vertex, list) in adjList { + let list = list.map { $0.val } + Swift.print("\(vertex.val): \(list),") + } + } + } + ``` + +=== "JS" + + ```javascript title="graph_adjacency_list.js" + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + class GraphAdjList { + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + adjList; + + /* 생성자 */ + constructor(edges) { + this.adjList = new Map(); + // 모든 정점과 간선을 추가한다 + for (const edge of edges) { + this.addVertex(edge[0]); + this.addVertex(edge[1]); + this.addEdge(edge[0], edge[1]); + } + } + + /* 정점 개수 반환 */ + size() { + return this.adjList.size; + } + + /* 간선 추가 */ + addEdge(vet1, vet2) { + if ( + !this.adjList.has(vet1) || + !this.adjList.has(vet2) || + vet1 === vet2 + ) { + throw new Error('잘못된 인수'); + } + // 간선 vet1 - vet2를 추가한다 + this.adjList.get(vet1).push(vet2); + this.adjList.get(vet2).push(vet1); + } + + /* 간선 삭제 */ + removeEdge(vet1, vet2) { + if ( + !this.adjList.has(vet1) || + !this.adjList.has(vet2) || + vet1 === vet2 || + this.adjList.get(vet1).indexOf(vet2) === -1 + ) { + throw new Error('잘못된 인수'); + } + // 간선 vet1 - vet2를 삭제한다 + this.adjList.get(vet1).splice(this.adjList.get(vet1).indexOf(vet2), 1); + this.adjList.get(vet2).splice(this.adjList.get(vet2).indexOf(vet1), 1); + } + + /* 정점 추가 */ + addVertex(vet) { + if (this.adjList.has(vet)) return; + // 인접 리스트에 새 연결 리스트를 추가한다 + this.adjList.set(vet, []); + } + + /* 정점 삭제 */ + removeVertex(vet) { + if (!this.adjList.has(vet)) { + throw new Error('잘못된 인수'); + } + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + this.adjList.delete(vet); + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for (const set of this.adjList.values()) { + const index = set.indexOf(vet); + if (index > -1) { + set.splice(index, 1); + } + } + } + + /* 인접 리스트 출력 */ + print() { + console.log('인접 리스트 ='); + for (const [key, value] of this.adjList) { + const tmp = []; + for (const vertex of value) { + tmp.push(vertex.val); + } + console.log(key.val + ': ' + tmp.join()); + } + } + } + ``` + +=== "TS" + + ```typescript title="graph_adjacency_list.ts" + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + class GraphAdjList { + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + adjList: Map; + + /* 생성자 */ + constructor(edges: Vertex[][]) { + this.adjList = new Map(); + // 모든 정점과 간선을 추가한다 + for (const edge of edges) { + this.addVertex(edge[0]); + this.addVertex(edge[1]); + this.addEdge(edge[0], edge[1]); + } + } + + /* 정점 개수 반환 */ + size(): number { + return this.adjList.size; + } + + /* 간선 추가 */ + addEdge(vet1: Vertex, vet2: Vertex): void { + if ( + !this.adjList.has(vet1) || + !this.adjList.has(vet2) || + vet1 === vet2 + ) { + throw new Error('잘못된 인수'); + } + // 간선 vet1 - vet2를 추가한다 + this.adjList.get(vet1).push(vet2); + this.adjList.get(vet2).push(vet1); + } + + /* 간선 삭제 */ + removeEdge(vet1: Vertex, vet2: Vertex): void { + if ( + !this.adjList.has(vet1) || + !this.adjList.has(vet2) || + vet1 === vet2 || + this.adjList.get(vet1).indexOf(vet2) === -1 + ) { + throw new Error('잘못된 인수'); + } + // 간선 vet1 - vet2를 삭제한다 + this.adjList.get(vet1).splice(this.adjList.get(vet1).indexOf(vet2), 1); + this.adjList.get(vet2).splice(this.adjList.get(vet2).indexOf(vet1), 1); + } + + /* 정점 추가 */ + addVertex(vet: Vertex): void { + if (this.adjList.has(vet)) return; + // 인접 리스트에 새 연결 리스트를 추가한다 + this.adjList.set(vet, []); + } + + /* 정점 삭제 */ + removeVertex(vet: Vertex): void { + if (!this.adjList.has(vet)) { + throw new Error('잘못된 인수'); + } + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + this.adjList.delete(vet); + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for (const set of this.adjList.values()) { + const index: number = set.indexOf(vet); + if (index > -1) { + set.splice(index, 1); + } + } + } + + /* 인접 리스트 출력 */ + print(): void { + console.log('인접 리스트 ='); + for (const [key, value] of this.adjList.entries()) { + const tmp = []; + for (const vertex of value) { + tmp.push(vertex.val); + } + console.log(key.val + ': ' + tmp.join()); + } + } + } + ``` + +=== "Dart" + + ```dart title="graph_adjacency_list.dart" + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + class GraphAdjList { + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + Map> adjList = {}; + + /* 생성자 */ + GraphAdjList(List> edges) { + for (List edge in edges) { + addVertex(edge[0]); + addVertex(edge[1]); + addEdge(edge[0], edge[1]); + } + } + + /* 정점 개수 반환 */ + int size() { + return adjList.length; + } + + /* 간선 추가 */ + void addEdge(Vertex vet1, Vertex vet2) { + if (!adjList.containsKey(vet1) || + !adjList.containsKey(vet2) || + vet1 == vet2) { + throw ArgumentError; + } + // 간선 vet1 - vet2를 추가한다 + adjList[vet1]!.add(vet2); + adjList[vet2]!.add(vet1); + } + + /* 간선 삭제 */ + void removeEdge(Vertex vet1, Vertex vet2) { + if (!adjList.containsKey(vet1) || + !adjList.containsKey(vet2) || + vet1 == vet2) { + throw ArgumentError; + } + // 간선 vet1 - vet2를 삭제한다 + adjList[vet1]!.remove(vet2); + adjList[vet2]!.remove(vet1); + } + + /* 정점 추가 */ + void addVertex(Vertex vet) { + if (adjList.containsKey(vet)) return; + // 인접 리스트에 새 연결 리스트를 추가한다 + adjList[vet] = []; + } + + /* 정점 삭제 */ + void removeVertex(Vertex vet) { + if (!adjList.containsKey(vet)) { + throw ArgumentError; + } + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + adjList.remove(vet); + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + adjList.forEach((key, value) { + value.remove(vet); + }); + } + + /* 인접 리스트 출력 */ + void printAdjList() { + print("인접 리스트 ="); + adjList.forEach((key, value) { + List tmp = []; + for (Vertex vertex in value) { + tmp.add(vertex.val); + } + print("${key.val}: $tmp,"); + }); + } + } + ``` + +=== "Rust" + + ```rust title="graph_adjacency_list.rs" + /* 인접 리스트로 구현한 무방향 그래프 자료형 */ + pub struct GraphAdjList { + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + pub adj_list: HashMap>, // 값 부분에는 HashSet가 더 적합할까? + } + + impl GraphAdjList { + /* 생성자 */ + pub fn new(edges: Vec<[Vertex; 2]>) -> Self { + let mut graph = GraphAdjList { + adj_list: HashMap::new(), + }; + // 모든 정점과 간선을 추가한다 + for edge in edges { + graph.add_vertex(edge[0]); + graph.add_vertex(edge[1]); + graph.add_edge(edge[0], edge[1]); + } + + graph + } + + /* 정점 개수 반환 */ + #[allow(unused)] + pub fn size(&self) -> usize { + self.adj_list.len() + } + + /* 간선 추가 */ + pub fn add_edge(&mut self, vet1: Vertex, vet2: Vertex) { + if vet1 == vet2 { + panic!("값 오류"); + } + // 간선 vet1 - vet2를 추가한다 + self.adj_list.entry(vet1).or_default().push(vet2); + self.adj_list.entry(vet2).or_default().push(vet1); + } + + /* 간선 삭제 */ + #[allow(unused)] + pub fn remove_edge(&mut self, vet1: Vertex, vet2: Vertex) { + if vet1 == vet2 { + panic!("값 오류"); + } + // 간선 vet1 - vet2를 삭제한다 + self.adj_list + .entry(vet1) + .and_modify(|v| v.retain(|&e| e != vet2)); + self.adj_list + .entry(vet2) + .and_modify(|v| v.retain(|&e| e != vet1)); + } + + /* 정점 추가 */ + pub fn add_vertex(&mut self, vet: Vertex) { + if self.adj_list.contains_key(&vet) { + return; + } + // 인접 리스트에 새 연결 리스트를 추가한다 + self.adj_list.insert(vet, vec![]); + } + + /* 정점 삭제 */ + #[allow(unused)] + pub fn remove_vertex(&mut self, vet: Vertex) { + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + self.adj_list.remove(&vet); + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for list in self.adj_list.values_mut() { + list.retain(|&v| v != vet); + } + } + + /* 인접 리스트 출력 */ + pub fn print(&self) { + println!("인접 리스트 ="); + for (vertex, list) in &self.adj_list { + let list = list.iter().map(|vertex| vertex.val).collect::>(); + println!("{}: {:?},", vertex.val, list); + } + } + } + ``` + +=== "C" + + ```c title="graph_adjacency_list.c" + /* 노드 구조체 */ + typedef struct AdjListNode { + Vertex *vertex; // 정점 + struct AdjListNode *next; // 다음 노드 + } AdjListNode; + + /* 정점에 해당하는 노드 찾기 */ + AdjListNode *findNode(GraphAdjList *graph, Vertex *vet) { + for (int i = 0; i < graph->size; i++) { + if (graph->heads[i]->vertex == vet) { + return graph->heads[i]; + } + } + return NULL; + } + + /* 간선 추가 보조 함수 */ + void addEdgeHelper(AdjListNode *head, Vertex *vet) { + AdjListNode *node = (AdjListNode *)malloc(sizeof(AdjListNode)); + node->vertex = vet; + // 맨 앞에 삽입한다 + node->next = head->next; + head->next = node; + } + + /* 간선 삭제 보조 함수 */ + void removeEdgeHelper(AdjListNode *head, Vertex *vet) { + AdjListNode *pre = head; + AdjListNode *cur = head->next; + // 연결 리스트에서 vet에 해당하는 노드를 찾는다 + while (cur != NULL && cur->vertex != vet) { + pre = cur; + cur = cur->next; + } + if (cur == NULL) + return; + // 연결 리스트에서 vet에 해당하는 노드를 삭제한다 + pre->next = cur->next; + // 메모리 해제 + free(cur); + } + + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + typedef struct { + AdjListNode *heads[MAX_SIZE]; // 노드 배열 + int size; // 노드 개수 + } GraphAdjList; + + /* 생성자 */ + GraphAdjList *newGraphAdjList() { + GraphAdjList *graph = (GraphAdjList *)malloc(sizeof(GraphAdjList)); + if (!graph) { + return NULL; + } + graph->size = 0; + for (int i = 0; i < MAX_SIZE; i++) { + graph->heads[i] = NULL; + } + return graph; + } + + /* 소멸자 */ + void delGraphAdjList(GraphAdjList *graph) { + for (int i = 0; i < graph->size; i++) { + AdjListNode *cur = graph->heads[i]; + while (cur != NULL) { + AdjListNode *next = cur->next; + if (cur != graph->heads[i]) { + free(cur); + } + cur = next; + } + free(graph->heads[i]->vertex); + free(graph->heads[i]); + } + free(graph); + } + + /* 정점에 해당하는 노드 찾기 */ + AdjListNode *findNode(GraphAdjList *graph, Vertex *vet) { + for (int i = 0; i < graph->size; i++) { + if (graph->heads[i]->vertex == vet) { + return graph->heads[i]; + } + } + return NULL; + } + + /* 간선 추가 */ + void addEdge(GraphAdjList *graph, Vertex *vet1, Vertex *vet2) { + AdjListNode *head1 = findNode(graph, vet1); + AdjListNode *head2 = findNode(graph, vet2); + assert(head1 != NULL && head2 != NULL && head1 != head2); + // 간선 vet1 - vet2를 추가한다 + addEdgeHelper(head1, vet2); + addEdgeHelper(head2, vet1); + } + + /* 간선 삭제 */ + void removeEdge(GraphAdjList *graph, Vertex *vet1, Vertex *vet2) { + AdjListNode *head1 = findNode(graph, vet1); + AdjListNode *head2 = findNode(graph, vet2); + assert(head1 != NULL && head2 != NULL); + // 간선 vet1 - vet2를 삭제한다 + removeEdgeHelper(head1, head2->vertex); + removeEdgeHelper(head2, head1->vertex); + } + + /* 정점 추가 */ + void addVertex(GraphAdjList *graph, Vertex *vet) { + assert(graph != NULL && graph->size < MAX_SIZE); + AdjListNode *head = (AdjListNode *)malloc(sizeof(AdjListNode)); + head->vertex = vet; + head->next = NULL; + // 인접 리스트에 새 연결 리스트를 추가한다 + graph->heads[graph->size++] = head; + } + + /* 정점 삭제 */ + void removeVertex(GraphAdjList *graph, Vertex *vet) { + AdjListNode *node = findNode(graph, vet); + assert(node != NULL); + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + AdjListNode *cur = node, *pre = NULL; + while (cur) { + pre = cur; + cur = cur->next; + free(pre); + } + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for (int i = 0; i < graph->size; i++) { + cur = graph->heads[i]; + pre = NULL; + while (cur) { + pre = cur; + cur = cur->next; + if (cur && cur->vertex == vet) { + pre->next = cur->next; + free(cur); + break; + } + } + } + // 해당 정점 뒤의 정점을 앞으로 옮겨 빈자리를 채운다 + int i; + for (i = 0; i < graph->size; i++) { + if (graph->heads[i] == node) + break; + } + for (int j = i; j < graph->size - 1; j++) { + graph->heads[j] = graph->heads[j + 1]; + } + graph->size--; + free(vet); + } + ``` + +=== "Kotlin" + + ```kotlin title="graph_adjacency_list.kt" + /* 인접 리스트로 구현한 무방향 그래프 클래스 */ + class GraphAdjList(edges: Array>) { + // 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + val adjList = HashMap>() + + /* 생성자 */ + init { + // 모든 정점과 간선을 추가한다 + for (edge in edges) { + addVertex(edge[0]!!) + addVertex(edge[1]!!) + addEdge(edge[0]!!, edge[1]!!) + } + } + + /* 정점 개수 반환 */ + fun size(): Int { + return adjList.size + } + + /* 간선 추가 */ + fun addEdge(vet1: Vertex, vet2: Vertex) { + if (!adjList.containsKey(vet1) || !adjList.containsKey(vet2) || vet1 == vet2) + throw IllegalArgumentException() + // 간선 vet1 - vet2를 추가한다 + adjList[vet1]?.add(vet2) + adjList[vet2]?.add(vet1) + } + + /* 간선 삭제 */ + fun removeEdge(vet1: Vertex, vet2: Vertex) { + if (!adjList.containsKey(vet1) || !adjList.containsKey(vet2) || vet1 == vet2) + throw IllegalArgumentException() + // 간선 vet1 - vet2를 삭제한다 + adjList[vet1]?.remove(vet2) + adjList[vet2]?.remove(vet1) + } + + /* 정점 추가 */ + fun addVertex(vet: Vertex) { + if (adjList.containsKey(vet)) + return + // 인접 리스트에 새 연결 리스트를 추가한다 + adjList[vet] = mutableListOf() + } + + /* 정점 삭제 */ + fun removeVertex(vet: Vertex) { + if (!adjList.containsKey(vet)) + throw IllegalArgumentException() + // 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + adjList.remove(vet) + // 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for (list in adjList.values) { + list.remove(vet) + } + } + + /* 인접 리스트 출력 */ + fun print() { + println("인접 리스트 =") + for (pair in adjList.entries) { + val tmp = mutableListOf() + for (vertex in pair.value) { + tmp.add(vertex._val) + } + println("${pair.key._val}: $tmp,") + } + } + } + ``` + +=== "Ruby" + + ```ruby title="graph_adjacency_list.rb" + ### 인접 리스트로 구현한 무방향 그래프 클래스 ### + class GraphAdjList + attr_reader :adj_list + + ### 생성자 ### + def initialize(edges) + # 인접 리스트의 key: 정점, value: 해당 정점의 모든 인접 정점 + @adj_list = {} + # 모든 정점과 간선을 추가한다 + for edge in edges + add_vertex(edge[0]) + add_vertex(edge[1]) + add_edge(edge[0], edge[1]) + end + end + + ### 정점 개수 반환 ### + def size + @adj_list.length + end + + ### 간선 추가 ### + def add_edge(vet1, vet2) + raise ArgumentError if !@adj_list.include?(vet1) || !@adj_list.include?(vet2) + + @adj_list[vet1] << vet2 + @adj_list[vet2] << vet1 + end + + ### 간선 삭제 ### + def remove_edge(vet1, vet2) + raise ArgumentError if !@adj_list.include?(vet1) || !@adj_list.include?(vet2) + + # 간선 vet1 - vet2를 삭제한다 + @adj_list[vet1].delete(vet2) + @adj_list[vet2].delete(vet1) + end + + ### 정점 추가 ### + def add_vertex(vet) + return if @adj_list.include?(vet) + + # 인접 리스트에 새 연결 리스트를 추가한다 + @adj_list[vet] = [] + end + + ### 정점 삭제 ### + def remove_vertex(vet) + raise ArgumentError unless @adj_list.include?(vet) + + # 인접 리스트에서 정점 vet에 해당하는 연결 리스트를 삭제한다 + @adj_list.delete(vet) + # 다른 정점의 연결 리스트를 순회하여 vet를 포함하는 모든 간선을 삭제한다 + for vertex in @adj_list + @adj_list[vertex.first].delete(vet) if @adj_list[vertex.first].include?(vet) + end + end + + ### 인접 리스트 출력 ### + def __print__ + puts '인접 리스트 =' + for vertex in @adj_list + tmp = @adj_list[vertex.first].map { |v| v.val } + puts "#{vertex.first.val}: #{tmp}," + end + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 9.2.3   효율 비교 + +그래프에 정점 $n$ 개와 간선 $m$ 개가 있다고 하자. 아래 표(9-2)는 인접 행렬과 인접 리스트의 시간 효율과 공간 효율을 비교한다. 여기서 인접 리스트(연결 리스트)는 이 절의 구현에 해당하며, 인접 리스트(해시 테이블)는 모든 연결 리스트를 해시 테이블로 바꾼 구현을 뜻한다. + +

표 9-2   인접 행렬과 인접 리스트 비교

+ +
+ +| | 인접 행렬 | 인접 리스트(연결 리스트) | 인접 리스트(해시 테이블) | +| ------------ | -------- | -------------- | ---------------- | +| 인접 여부 확인 | $O(1)$ | $O(n)$ | $O(1)$ | +| 간선 추가 | $O(1)$ | $O(1)$ | $O(1)$ | +| 간선 삭제 | $O(1)$ | $O(n)$ | $O(1)$ | +| 정점 추가 | $O(n)$ | $O(1)$ | $O(1)$ | +| 정점 삭제 | $O(n^2)$ | $O(n + m)$ | $O(n)$ | +| 메모리 사용량 | $O(n^2)$ | $O(n + m)$ | $O(n + m)$ | + +
+ +위 표(9-2)만 보면 인접 리스트(해시 테이블)의 시간 효율과 공간 효율이 가장 좋아 보인다. 하지만 실제로는 배열 접근이나 대입 한 번이면 되는 인접 행렬의 간선 연산이 더 효율적이다. 종합하면 인접 행렬은 '공간을 더 써서 시간을 절약하는' 원리를, 인접 리스트는 '시간을 더 써서 공간을 절약하는' 원리를 보여 준다. diff --git a/ko/docs/chapter_graph/graph_traversal.md b/ko/docs/chapter_graph/graph_traversal.md new file mode 100644 index 000000000..17302c0b1 --- /dev/null +++ b/ko/docs/chapter_graph/graph_traversal.md @@ -0,0 +1,995 @@ +--- +comments: true +--- + +# 9.3   그래프 순회 + +트리는 '일대다' 관계를 나타내지만, 그래프는 자유도가 더 높아 임의의 '다대다' 관계를 표현할 수 있다. 따라서 트리는 그래프의 특수한 경우로 볼 수 있다. **트리 순회 역시 그래프 순회의 특수한 경우다**. + +그래프와 트리 모두 탐색 알고리즘을 이용하여 순회한다. 그래프 순회도 너비 우선 순회와 깊이 우선 순회의 두 가지로 나뉜다. + +## 9.3.1   너비 우선 순회 + +**너비 우선 순회는 가까운 곳에서 먼 곳으로 진행하는 순회 방식이다. 한 노드에서 출발하여 항상 거리가 가장 가까운 정점을 먼저 방문하고, 한 층씩 바깥으로 확장한다**. 아래 그림(9-9)에서는 왼쪽 위 정점에서 출발하여 해당 정점의 모든 인접 정점을 먼저 순회한 다음, 다음 정점의 모든 인접 정점을 순회한다. 이를 모든 정점을 방문할 때까지 반복한다. + +![그래프의 너비 우선 순회](graph_traversal.assets/graph_bfs.png){ class="animation-figure" } + +

그림 9-9   그래프의 너비 우선 순회

+ +### 1.   알고리즘 구현 + +BFS는 보통 큐로 구현하며, 코드는 다음과 같다. 큐의 '선입선출' 성질은 BFS의 '가까운 곳부터 먼 곳으로'라는 발상과 잘 맞는다. + +1. 순회의 시작 정점 `startVet`를 큐에 넣고 반복문을 시작한다. +2. 매 반복에서 큐의 맨 앞 정점을 꺼내 방문을 기록한 다음, 해당 정점의 모든 인접 정점을 큐의 끝에 넣는다. +3. 모든 정점을 방문할 때까지 `2.` 단계를 반복한 뒤 종료한다. + +같은 정점을 여러 번 순회하지 않도록 해시 집합 `visited`를 사용하여 방문한 노드를 기록한다. + +!!! tip "참고" + + 해시 집합은 `value` 없이 `key`만 저장하는 해시 테이블로 볼 수 있다. `key`의 추가, 삭제, 조회, 수정 연산을 $O(1)$ 시간에 수행한다. `key`는 중복될 수 없으므로, 해시 집합은 데이터 중복 제거 등에 자주 사용된다. + +=== "Python" + + ```python title="graph_bfs.py" + def graph_bfs(graph: GraphAdjList, start_vet: Vertex) -> list[Vertex]: + """너비 우선 순회""" + # 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + # 정점 순회 순서 + res = [] + # 방문한 정점을 기록하는 해시 집합 + visited = set[Vertex]([start_vet]) + # 큐를 사용하여 BFS를 구현한다 + que = deque[Vertex]([start_vet]) + # 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while len(que) > 0: + vet = que.popleft() # 큐 맨 앞의 정점을 꺼낸다 + res.append(vet) # 방문한 정점을 기록한다 + # 해당 정점의 모든 인접 정점을 순회한다 + for adj_vet in graph.adj_list[vet]: + if adj_vet in visited: + continue # 이미 방문한 정점은 건너뛴다 + que.append(adj_vet) # 방문하지 않은 정점만 큐에 삽입한다 + visited.add(adj_vet) # 해당 정점을 방문했다고 표시한다 + # 정점 순회 순서를 반환한다 + return res + ``` + +=== "C++" + + ```cpp title="graph_bfs.cpp" + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + vector graphBFS(GraphAdjList &graph, Vertex *startVet) { + // 정점 순회 순서 + vector res; + // 방문한 정점을 기록하는 해시 집합 + unordered_set visited = {startVet}; + // 큐를 사용하여 BFS를 구현한다 + queue que; + que.push(startVet); + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while (!que.empty()) { + Vertex *vet = que.front(); + que.pop(); // 큐 맨 앞의 정점을 꺼낸다 + res.push_back(vet); // 방문한 정점을 기록한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (auto adjVet : graph.adjList[vet]) { + if (visited.count(adjVet)) + continue; // 이미 방문한 정점은 건너뛴다 + que.push(adjVet); // 방문하지 않은 정점만 큐에 삽입한다 + visited.emplace(adjVet); // 해당 정점을 방문했다고 표시한다 + } + } + // 정점 순회 순서를 반환한다 + return res; + } + ``` + +=== "Java" + + ```java title="graph_bfs.java" + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + List graphBFS(GraphAdjList graph, Vertex startVet) { + // 정점 순회 순서 + List res = new ArrayList<>(); + // 방문한 정점을 기록하는 해시 집합 + Set visited = new HashSet<>(); + visited.add(startVet); + // 큐를 사용하여 BFS를 구현한다 + Queue que = new LinkedList<>(); + que.offer(startVet); + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while (!que.isEmpty()) { + Vertex vet = que.poll(); // 큐 맨 앞의 정점을 꺼낸다 + res.add(vet); // 방문한 정점을 기록한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (Vertex adjVet : graph.adjList.get(vet)) { + if (visited.contains(adjVet)) + continue; // 이미 방문한 정점은 건너뛴다 + que.offer(adjVet); // 방문하지 않은 정점만 큐에 삽입한다 + visited.add(adjVet); // 해당 정점을 방문했다고 표시한다 + } + } + // 정점 순회 순서를 반환한다 + return res; + } + ``` + +=== "C#" + + ```csharp title="graph_bfs.cs" + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + List GraphBFS(GraphAdjList graph, Vertex startVet) { + // 정점 순회 순서 + List res = []; + // 방문한 정점을 기록하는 해시 집합 + HashSet visited = [startVet]; + // 큐를 사용하여 BFS를 구현한다 + Queue que = new(); + que.Enqueue(startVet); + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while (que.Count > 0) { + Vertex vet = que.Dequeue(); // 큐 맨 앞의 정점을 꺼낸다 + res.Add(vet); // 방문한 정점을 기록한다 + foreach (Vertex adjVet in graph.adjList[vet]) { + if (visited.Contains(adjVet)) { + continue; // 이미 방문한 정점은 건너뛴다 + } + que.Enqueue(adjVet); // 방문하지 않은 정점만 큐에 삽입한다 + visited.Add(adjVet); // 해당 정점을 방문했다고 표시한다 + } + } + + // 정점 순회 순서를 반환한다 + return res; + } + ``` + +=== "Go" + + ```go title="graph_bfs.go" + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + func graphBFS(g *graphAdjList, startVet Vertex) []Vertex { + // 정점 순회 순서 + res := make([]Vertex, 0) + // 방문한 정점을 기록하는 해시 집합 + visited := make(map[Vertex]struct{}) + visited[startVet] = struct{}{} + // 큐로 BFS를 구현하며, 슬라이스로 큐를 구현한다 + queue := make([]Vertex, 0) + queue = append(queue, startVet) + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + for len(queue) > 0 { + // 큐 맨 앞의 정점을 꺼낸다 + vet := queue[0] + queue = queue[1:] + // 방문한 정점을 기록한다 + res = append(res, vet) + // 해당 정점의 모든 인접 정점을 순회한다 + for _, adjVet := range g.adjList[vet] { + _, isExist := visited[adjVet] + // 방문하지 않은 정점만 큐에 삽입한다 + if !isExist { + queue = append(queue, adjVet) + visited[adjVet] = struct{}{} + } + } + } + // 정점 순회 순서를 반환한다 + return res + } + ``` + +=== "Swift" + + ```swift title="graph_bfs.swift" + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + func graphBFS(graph: GraphAdjList, startVet: Vertex) -> [Vertex] { + // 정점 순회 순서 + var res: [Vertex] = [] + // 방문한 정점을 기록하는 해시 집합 + var visited: Set = [startVet] + // 큐를 사용하여 BFS를 구현한다 + var que: [Vertex] = [startVet] + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while !que.isEmpty { + let vet = que.removeFirst() // 큐 맨 앞의 정점을 꺼낸다 + res.append(vet) // 방문한 정점을 기록한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for adjVet in graph.adjList[vet] ?? [] { + if visited.contains(adjVet) { + continue // 이미 방문한 정점은 건너뛴다 + } + que.append(adjVet) // 방문하지 않은 정점만 큐에 삽입한다 + visited.insert(adjVet) // 해당 정점을 방문했다고 표시한다 + } + } + // 정점 순회 순서를 반환한다 + return res + } + ``` + +=== "JS" + + ```javascript title="graph_bfs.js" + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + function graphBFS(graph, startVet) { + // 정점 순회 순서 + const res = []; + // 방문한 정점을 기록하는 해시 집합 + const visited = new Set(); + visited.add(startVet); + // 큐를 사용하여 BFS를 구현한다 + const que = [startVet]; + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while (que.length) { + const vet = que.shift(); // 큐 맨 앞의 정점을 꺼낸다 + res.push(vet); // 방문한 정점을 기록한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (const adjVet of graph.adjList.get(vet) ?? []) { + if (visited.has(adjVet)) { + continue; // 이미 방문한 정점은 건너뛴다 + } + que.push(adjVet); // 방문하지 않은 정점만 큐에 삽입한다 + visited.add(adjVet); // 해당 정점을 방문했다고 표시한다 + } + } + // 정점 순회 순서를 반환한다 + return res; + } + ``` + +=== "TS" + + ```typescript title="graph_bfs.ts" + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + function graphBFS(graph: GraphAdjList, startVet: Vertex): Vertex[] { + // 정점 순회 순서 + const res: Vertex[] = []; + // 방문한 정점을 기록하는 해시 집합 + const visited: Set = new Set(); + visited.add(startVet); + // 큐를 사용하여 BFS를 구현한다 + const que = [startVet]; + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while (que.length) { + const vet = que.shift(); // 큐 맨 앞의 정점을 꺼낸다 + res.push(vet); // 방문한 정점을 기록한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (const adjVet of graph.adjList.get(vet) ?? []) { + if (visited.has(adjVet)) { + continue; // 이미 방문한 정점은 건너뛴다 + } + que.push(adjVet); // 방문하지 않은 정점만 큐에 삽입한다 + visited.add(adjVet); // 해당 정점을 방문했다고 표시한다 + } + } + // 정점 순회 순서를 반환한다 + return res; + } + ``` + +=== "Dart" + + ```dart title="graph_bfs.dart" + /* 너비 우선 순회 */ + List graphBFS(GraphAdjList graph, Vertex startVet) { + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + // 정점 순회 순서 + List res = []; + // 방문한 정점을 기록하는 해시 집합 + Set visited = {}; + visited.add(startVet); + // 큐를 사용하여 BFS를 구현한다 + Queue que = Queue(); + que.add(startVet); + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while (que.isNotEmpty) { + Vertex vet = que.removeFirst(); // 큐 맨 앞의 정점을 꺼낸다 + res.add(vet); // 방문한 정점을 기록한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (Vertex adjVet in graph.adjList[vet]!) { + if (visited.contains(adjVet)) { + continue; // 이미 방문한 정점은 건너뛴다 + } + que.add(adjVet); // 방문하지 않은 정점만 큐에 삽입한다 + visited.add(adjVet); // 해당 정점을 방문했다고 표시한다 + } + } + // 정점 순회 순서를 반환한다 + return res; + } + ``` + +=== "Rust" + + ```rust title="graph_bfs.rs" + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + fn graph_bfs(graph: GraphAdjList, start_vet: Vertex) -> Vec { + // 정점 순회 순서 + let mut res = vec![]; + // 방문한 정점을 기록하는 해시 집합 + let mut visited = HashSet::new(); + visited.insert(start_vet); + // 큐를 사용하여 BFS를 구현한다 + let mut que = VecDeque::new(); + que.push_back(start_vet); + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while let Some(vet) = que.pop_front() { + res.push(vet); // 방문한 정점을 기록한다 + + // 해당 정점의 모든 인접 정점을 순회한다 + if let Some(adj_vets) = graph.adj_list.get(&vet) { + for &adj_vet in adj_vets { + if visited.contains(&adj_vet) { + continue; // 이미 방문한 정점은 건너뛴다 + } + que.push_back(adj_vet); // 방문하지 않은 정점만 큐에 삽입한다 + visited.insert(adj_vet); // 해당 정점을 방문했다고 표시한다 + } + } + } + // 정점 순회 순서를 반환한다 + res + } + ``` + +=== "C" + + ```c title="graph_bfs.c" + /* 노드 큐 구조체 */ + typedef struct { + Vertex *vertices[MAX_SIZE]; + int front, rear, size; + } Queue; + + /* 생성자 */ + Queue *newQueue() { + Queue *q = (Queue *)malloc(sizeof(Queue)); + q->front = q->rear = q->size = 0; + return q; + } + + /* 큐가 비어 있는지 판단 */ + int isEmpty(Queue *q) { + return q->size == 0; + } + + /* 큐 삽입 연산 */ + void enqueue(Queue *q, Vertex *vet) { + q->vertices[q->rear] = vet; + q->rear = (q->rear + 1) % MAX_SIZE; + q->size++; + } + + /* 큐에서 꺼내는 연산 */ + Vertex *dequeue(Queue *q) { + Vertex *vet = q->vertices[q->front]; + q->front = (q->front + 1) % MAX_SIZE; + q->size--; + return vet; + } + + /* 정점을 이미 방문했는지 확인 */ + int isVisited(Vertex **visited, int size, Vertex *vet) { + // O(n) 시간에 순회하여 노드를 찾는다 + for (int i = 0; i < size; i++) { + if (visited[i] == vet) + return 1; + } + return 0; + } + + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + void graphBFS(GraphAdjList *graph, Vertex *startVet, Vertex **res, int *resSize, Vertex **visited, int *visitedSize) { + // 큐를 사용하여 BFS를 구현한다 + Queue *queue = newQueue(); + enqueue(queue, startVet); + visited[(*visitedSize)++] = startVet; + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while (!isEmpty(queue)) { + Vertex *vet = dequeue(queue); // 큐 맨 앞의 정점을 꺼낸다 + res[(*resSize)++] = vet; // 방문한 정점을 기록한다 + // 해당 정점의 모든 인접 정점을 순회한다 + AdjListNode *node = findNode(graph, vet); + while (node != NULL) { + // 이미 방문한 정점은 건너뛴다 + if (!isVisited(visited, *visitedSize, node->vertex)) { + enqueue(queue, node->vertex); // 방문하지 않은 정점만 큐에 삽입한다 + visited[(*visitedSize)++] = node->vertex; // 해당 정점을 방문했다고 표시한다 + } + node = node->next; + } + } + // 메모리 해제 + free(queue); + } + ``` + +=== "Kotlin" + + ```kotlin title="graph_bfs.kt" + /* 너비 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + fun graphBFS(graph: GraphAdjList, startVet: Vertex): MutableList { + // 정점 순회 순서 + val res = mutableListOf() + // 방문한 정점을 기록하는 해시 집합 + val visited = HashSet() + visited.add(startVet) + // 큐를 사용하여 BFS를 구현한다 + val que = LinkedList() + que.offer(startVet) + // 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while (!que.isEmpty()) { + val vet = que.poll() // 큐 맨 앞의 정점을 꺼낸다 + res.add(vet) // 방문한 정점을 기록한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (adjVet in graph.adjList[vet]!!) { + if (visited.contains(adjVet)) + continue // 이미 방문한 정점은 건너뛴다 + que.offer(adjVet) // 방문하지 않은 정점만 큐에 삽입한다 + visited.add(adjVet) // 해당 정점을 방문했다고 표시한다 + } + } + // 정점 순회 순서를 반환한다 + return res + } + ``` + +=== "Ruby" + + ```ruby title="graph_bfs.rb" + ### 너비 우선 순회 ### + def graph_bfs(graph, start_vet) + # 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + # 정점 순회 순서 + res = [] + # 방문한 정점을 기록하는 해시 집합 + visited = Set.new([start_vet]) + # 큐를 사용하여 BFS를 구현한다 + que = [start_vet] + # 정점 vet에서 시작하여 모든 정점을 방문할 때까지 반복한다 + while que.length > 0 + vet = que.shift # 큐 맨 앞의 정점을 꺼낸다 + res << vet # 방문한 정점을 기록한다 + # 해당 정점의 모든 인접 정점을 순회한다 + for adj_vet in graph.adj_list[vet] + next if visited.include?(adj_vet) # 이미 방문한 정점은 건너뛴다 + que << adj_vet # 방문하지 않은 정점만 큐에 삽입한다 + visited.add(adj_vet) # 해당 정점을 방문했다고 표시한다 + end + end + # 정점 순회 순서를 반환한다 + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +코드만으로는 추상적일 수 있으므로 아래 그림(9-10)과 함께 살펴보며 이해를 깊이 다져 보자. + +=== "<1>" + ![그래프의 너비 우선 순회 단계](graph_traversal.assets/graph_bfs_step1.png){ class="animation-figure" } + +=== "<2>" + ![그래프 너비 우선 순회 2단계](graph_traversal.assets/graph_bfs_step2.png){ class="animation-figure" } + +=== "<3>" + ![그래프 너비 우선 순회 3단계](graph_traversal.assets/graph_bfs_step3.png){ class="animation-figure" } + +=== "<4>" + ![그래프 너비 우선 순회 4단계](graph_traversal.assets/graph_bfs_step4.png){ class="animation-figure" } + +=== "<5>" + ![그래프 너비 우선 순회 5단계](graph_traversal.assets/graph_bfs_step5.png){ class="animation-figure" } + +=== "<6>" + ![그래프 너비 우선 순회 6단계](graph_traversal.assets/graph_bfs_step6.png){ class="animation-figure" } + +=== "<7>" + ![그래프 너비 우선 순회 7단계](graph_traversal.assets/graph_bfs_step7.png){ class="animation-figure" } + +=== "<8>" + ![그래프 너비 우선 순회 8단계](graph_traversal.assets/graph_bfs_step8.png){ class="animation-figure" } + +=== "<9>" + ![그래프 너비 우선 순회 9단계](graph_traversal.assets/graph_bfs_step9.png){ class="animation-figure" } + +=== "<10>" + ![그래프 너비 우선 순회 10단계](graph_traversal.assets/graph_bfs_step10.png){ class="animation-figure" } + +=== "<11>" + ![그래프 너비 우선 순회 11단계](graph_traversal.assets/graph_bfs_step11.png){ class="animation-figure" } + +

그림 9-10   그래프의 너비 우선 순회 단계

+ +!!! question "너비 우선 순회 순서는 유일한가?" + + 유일하지 않다. 너비 우선 순회는 '가까운 곳부터 먼 곳으로' 순회하기만 하면 되므로, **거리가 같은 여러 정점의 방문 순서는 자유롭게 바꿀 수 있다**. 위 그림(9-10)에서는 정점 $1$, $3$의 방문 순서를 바꿀 수 있고, 정점 $2$, $4$, $6$의 순서도 자유롭게 바꿀 수 있다. + +### 2.   복잡도 분석 + +**시간 복잡도**: 모든 정점은 큐에 한 번 들어갔다가 한 번 나오므로 $O(|V|)$ 시간이 걸린다. 인접 정점을 순회할 때는 무방향 그래프이므로 모든 간선을 $2$ 번씩 방문하여 $O(2|E|)$ 시간이 걸린다. 전체 시간은 $O(|V| + |E|)$이다. + +**공간 복잡도**: 리스트 `res`, 해시 집합 `visited`, 큐 `que`에 저장되는 정점 수는 각각 최대 $|V|$이므로 $O(|V|)$ 공간을 사용한다. + +## 9.3.2   깊이 우선 순회 + +**깊이 우선 순회는 먼저 끝까지 나아가고, 더 갈 곳이 없으면 되돌아오는 순회 방식이다**. 아래 그림(9-11)에서는 왼쪽 위 정점에서 출발하여 현재 정점의 인접 정점 하나를 방문한다. 끝에 도달하면 되돌아오고, 다시 끝까지 나아갔다가 돌아오는 과정을 모든 정점을 순회할 때까지 반복한다. + +![그래프의 깊이 우선 순회](graph_traversal.assets/graph_dfs.png){ class="animation-figure" } + +

그림 9-11   그래프의 깊이 우선 순회

+ +### 1.   알고리즘 구현 + +'끝까지 갔다가 돌아오는' 알고리즘 패턴은 보통 재귀로 구현한다. 너비 우선 순회와 마찬가지로 깊이 우선 순회에서도 해시 집합 `visited`로 이미 방문한 정점을 기록하여 중복 방문을 방지한다. + +=== "Python" + + ```python title="graph_dfs.py" + def dfs(graph: GraphAdjList, visited: set[Vertex], res: list[Vertex], vet: Vertex): + """깊이 우선 순회 보조 함수""" + res.append(vet) # 방문한 정점을 기록한다 + visited.add(vet) # 해당 정점을 방문했다고 표시한다 + # 해당 정점의 모든 인접 정점을 순회한다 + for adjVet in graph.adj_list[vet]: + if adjVet in visited: + continue # 이미 방문한 정점은 건너뛴다 + # 인접 정점을 재귀적으로 방문한다 + dfs(graph, visited, res, adjVet) + + def graph_dfs(graph: GraphAdjList, start_vet: Vertex) -> list[Vertex]: + """깊이 우선 순회""" + # 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + # 정점 순회 순서 + res = [] + # 방문한 정점을 기록하는 해시 집합 + visited = set[Vertex]() + dfs(graph, visited, res, start_vet) + return res + ``` + +=== "C++" + + ```cpp title="graph_dfs.cpp" + /* 깊이 우선 순회 보조 함수 */ + void dfs(GraphAdjList &graph, unordered_set &visited, vector &res, Vertex *vet) { + res.push_back(vet); // 방문한 정점을 기록한다 + visited.emplace(vet); // 해당 정점을 방문했다고 표시한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (Vertex *adjVet : graph.adjList[vet]) { + if (visited.count(adjVet)) + continue; // 이미 방문한 정점은 건너뛴다 + // 인접 정점을 재귀적으로 방문한다 + dfs(graph, visited, res, adjVet); + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + vector graphDFS(GraphAdjList &graph, Vertex *startVet) { + // 정점 순회 순서 + vector res; + // 방문한 정점을 기록하는 해시 집합 + unordered_set visited; + dfs(graph, visited, res, startVet); + return res; + } + ``` + +=== "Java" + + ```java title="graph_dfs.java" + /* 깊이 우선 순회 보조 함수 */ + void dfs(GraphAdjList graph, Set visited, List res, Vertex vet) { + res.add(vet); // 방문한 정점을 기록한다 + visited.add(vet); // 해당 정점을 방문했다고 표시한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (Vertex adjVet : graph.adjList.get(vet)) { + if (visited.contains(adjVet)) + continue; // 이미 방문한 정점은 건너뛴다 + // 인접 정점을 재귀적으로 방문한다 + dfs(graph, visited, res, adjVet); + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + List graphDFS(GraphAdjList graph, Vertex startVet) { + // 정점 순회 순서 + List res = new ArrayList<>(); + // 방문한 정점을 기록하는 해시 집합 + Set visited = new HashSet<>(); + dfs(graph, visited, res, startVet); + return res; + } + ``` + +=== "C#" + + ```csharp title="graph_dfs.cs" + /* 깊이 우선 순회 보조 함수 */ + void DFS(GraphAdjList graph, HashSet visited, List res, Vertex vet) { + res.Add(vet); // 방문한 정점을 기록한다 + visited.Add(vet); // 해당 정점을 방문했다고 표시한다 + // 해당 정점의 모든 인접 정점을 순회한다 + foreach (Vertex adjVet in graph.adjList[vet]) { + if (visited.Contains(adjVet)) { + continue; // 이미 방문한 정점은 건너뛴다 + } + // 인접 정점을 재귀적으로 방문한다 + DFS(graph, visited, res, adjVet); + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + List GraphDFS(GraphAdjList graph, Vertex startVet) { + // 정점 순회 순서 + List res = []; + // 방문한 정점을 기록하는 해시 집합 + HashSet visited = []; + DFS(graph, visited, res, startVet); + return res; + } + ``` + +=== "Go" + + ```go title="graph_dfs.go" + /* 깊이 우선 순회 보조 함수 */ + func dfs(g *graphAdjList, visited map[Vertex]struct{}, res *[]Vertex, vet Vertex) { + // append 연산은 새 참조를 반환하므로 원래 참조에 새 슬라이스의 참조를 다시 대입해야 한다 + *res = append(*res, vet) + visited[vet] = struct{}{} + // 해당 정점의 모든 인접 정점을 순회한다 + for _, adjVet := range g.adjList[vet] { + _, isExist := visited[adjVet] + // 인접 정점을 재귀적으로 방문한다 + if !isExist { + dfs(g, visited, res, adjVet) + } + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + func graphDFS(g *graphAdjList, startVet Vertex) []Vertex { + // 정점 순회 순서 + res := make([]Vertex, 0) + // 방문한 정점을 기록하는 해시 집합 + visited := make(map[Vertex]struct{}) + dfs(g, visited, &res, startVet) + // 정점 순회 순서를 반환한다 + return res + } + ``` + +=== "Swift" + + ```swift title="graph_dfs.swift" + /* 깊이 우선 순회 보조 함수 */ + func dfs(graph: GraphAdjList, visited: inout Set, res: inout [Vertex], vet: Vertex) { + res.append(vet) // 방문한 정점을 기록한다 + visited.insert(vet) // 해당 정점을 방문했다고 표시한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for adjVet in graph.adjList[vet] ?? [] { + if visited.contains(adjVet) { + continue // 이미 방문한 정점은 건너뛴다 + } + // 인접 정점을 재귀적으로 방문한다 + dfs(graph: graph, visited: &visited, res: &res, vet: adjVet) + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + func graphDFS(graph: GraphAdjList, startVet: Vertex) -> [Vertex] { + // 정점 순회 순서 + var res: [Vertex] = [] + // 방문한 정점을 기록하는 해시 집합 + var visited: Set = [] + dfs(graph: graph, visited: &visited, res: &res, vet: startVet) + return res + } + ``` + +=== "JS" + + ```javascript title="graph_dfs.js" + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + function dfs(graph, visited, res, vet) { + res.push(vet); // 방문한 정점을 기록한다 + visited.add(vet); // 해당 정점을 방문했다고 표시한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (const adjVet of graph.adjList.get(vet)) { + if (visited.has(adjVet)) { + continue; // 이미 방문한 정점은 건너뛴다 + } + // 인접 정점을 재귀적으로 방문한다 + dfs(graph, visited, res, adjVet); + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + function graphDFS(graph, startVet) { + // 정점 순회 순서 + const res = []; + // 방문한 정점을 기록하는 해시 집합 + const visited = new Set(); + dfs(graph, visited, res, startVet); + return res; + } + ``` + +=== "TS" + + ```typescript title="graph_dfs.ts" + /* 깊이 우선 순회 보조 함수 */ + function dfs( + graph: GraphAdjList, + visited: Set, + res: Vertex[], + vet: Vertex + ): void { + res.push(vet); // 방문한 정점을 기록한다 + visited.add(vet); // 해당 정점을 방문했다고 표시한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (const adjVet of graph.adjList.get(vet)) { + if (visited.has(adjVet)) { + continue; // 이미 방문한 정점은 건너뛴다 + } + // 인접 정점을 재귀적으로 방문한다 + dfs(graph, visited, res, adjVet); + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + function graphDFS(graph: GraphAdjList, startVet: Vertex): Vertex[] { + // 정점 순회 순서 + const res: Vertex[] = []; + // 방문한 정점을 기록하는 해시 집합 + const visited: Set = new Set(); + dfs(graph, visited, res, startVet); + return res; + } + ``` + +=== "Dart" + + ```dart title="graph_dfs.dart" + /* 깊이 우선 순회 보조 함수 */ + void dfs( + GraphAdjList graph, + Set visited, + List res, + Vertex vet, + ) { + res.add(vet); // 방문한 정점을 기록한다 + visited.add(vet); // 해당 정점을 방문했다고 표시한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (Vertex adjVet in graph.adjList[vet]!) { + if (visited.contains(adjVet)) { + continue; // 이미 방문한 정점은 건너뛴다 + } + // 인접 정점을 재귀적으로 방문한다 + dfs(graph, visited, res, adjVet); + } + } + + /* 깊이 우선 순회 */ + List graphDFS(GraphAdjList graph, Vertex startVet) { + // 정점 순회 순서 + List res = []; + // 방문한 정점을 기록하는 해시 집합 + Set visited = {}; + dfs(graph, visited, res, startVet); + return res; + } + ``` + +=== "Rust" + + ```rust title="graph_dfs.rs" + /* 깊이 우선 순회 보조 함수 */ + fn dfs(graph: &GraphAdjList, visited: &mut HashSet, res: &mut Vec, vet: Vertex) { + res.push(vet); // 방문한 정점을 기록한다 + visited.insert(vet); // 해당 정점을 방문했다고 표시한다 + // 해당 정점의 모든 인접 정점을 순회한다 + if let Some(adj_vets) = graph.adj_list.get(&vet) { + for &adj_vet in adj_vets { + if visited.contains(&adj_vet) { + continue; // 이미 방문한 정점은 건너뛴다 + } + // 인접 정점을 재귀적으로 방문한다 + dfs(graph, visited, res, adj_vet); + } + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + fn graph_dfs(graph: GraphAdjList, start_vet: Vertex) -> Vec { + // 정점 순회 순서 + let mut res = vec![]; + // 방문한 정점을 기록하는 해시 집합 + let mut visited = HashSet::new(); + dfs(&graph, &mut visited, &mut res, start_vet); + + res + } + ``` + +=== "C" + + ```c title="graph_dfs.c" + /* 정점을 이미 방문했는지 확인 */ + int isVisited(Vertex **res, int size, Vertex *vet) { + // O(n) 시간에 순회하여 노드를 찾는다 + for (int i = 0; i < size; i++) { + if (res[i] == vet) { + return 1; + } + } + return 0; + } + + /* 깊이 우선 순회 보조 함수 */ + void dfs(GraphAdjList *graph, Vertex **res, int *resSize, Vertex *vet) { + // 방문한 정점을 기록한다 + res[(*resSize)++] = vet; + // 해당 정점의 모든 인접 정점을 순회한다 + AdjListNode *node = findNode(graph, vet); + while (node != NULL) { + // 이미 방문한 정점은 건너뛴다 + if (!isVisited(res, *resSize, node->vertex)) { + // 인접 정점을 재귀적으로 방문한다 + dfs(graph, res, resSize, node->vertex); + } + node = node->next; + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + void graphDFS(GraphAdjList *graph, Vertex *startVet, Vertex **res, int *resSize) { + dfs(graph, res, resSize, startVet); + } + ``` + +=== "Kotlin" + + ```kotlin title="graph_dfs.kt" + /* 깊이 우선 순회 보조 함수 */ + fun dfs( + graph: GraphAdjList, + visited: MutableSet, + res: MutableList, + vet: Vertex? + ) { + res.add(vet) // 방문한 정점을 기록한다 + visited.add(vet) // 해당 정점을 방문했다고 표시한다 + // 해당 정점의 모든 인접 정점을 순회한다 + for (adjVet in graph.adjList[vet]!!) { + if (visited.contains(adjVet)) + continue // 이미 방문한 정점은 건너뛴다 + // 인접 정점을 재귀적으로 방문한다 + dfs(graph, visited, res, adjVet) + } + } + + /* 깊이 우선 순회 */ + // 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + fun graphDFS(graph: GraphAdjList, startVet: Vertex?): MutableList { + // 정점 순회 순서 + val res = mutableListOf() + // 방문한 정점을 기록하는 해시 집합 + val visited = HashSet() + dfs(graph, visited, res, startVet) + return res + } + ``` + +=== "Ruby" + + ```ruby title="graph_dfs.rb" + ### 깊이 우선 순회 보조 함수 ### + def dfs(graph, visited, res, vet) + res << vet # 방문한 정점을 기록한다 + visited.add(vet) # 해당 정점을 방문했다고 표시한다 + # 해당 정점의 모든 인접 정점을 순회한다 + for adj_vet in graph.adj_list[vet] + next if visited.include?(adj_vet) # 이미 방문한 정점은 건너뛴다 + # 인접 정점을 재귀적으로 방문한다 + dfs(graph, visited, res, adj_vet) + end + end + + ### 깊이 우선 순회 ### + def graph_dfs(graph, start_vet) + # 지정한 정점의 모든 인접 정점을 얻기 쉽도록 그래프를 인접 리스트로 표현한다 + # 정점 순회 순서 + res = [] + # 방문한 정점을 기록하는 해시 집합 + visited = Set.new + dfs(graph, visited, res, start_vet) + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +깊이 우선 순회의 진행 과정은 아래 그림(9-12)과 같다. + +- **직선 점선은 아래로 내려가는 재귀 호출을 나타내며**, 새 정점을 방문하기 위해 새로운 재귀 메서드를 호출했다는 뜻이다. +- **곡선 점선은 위로 되돌아가는 백트래킹을 나타내며**, 재귀 메서드가 반환되어 이를 호출한 위치로 돌아왔다는 뜻이다. + +이해를 깊이 다지기 위해 아래 그림(9-12)과 코드를 함께 보면서, 각 재귀 메서드가 언제 호출되고 언제 반환되는지까지 포함하여 전체 DFS 과정을 머릿속으로 따라가 보자(또는 직접 그려 보자). + +=== "<1>" + ![그래프의 깊이 우선 순회 단계](graph_traversal.assets/graph_dfs_step1.png){ class="animation-figure" } + +=== "<2>" + ![그래프 깊이 우선 순회 2단계](graph_traversal.assets/graph_dfs_step2.png){ class="animation-figure" } + +=== "<3>" + ![그래프 깊이 우선 순회 3단계](graph_traversal.assets/graph_dfs_step3.png){ class="animation-figure" } + +=== "<4>" + ![그래프 깊이 우선 순회 4단계](graph_traversal.assets/graph_dfs_step4.png){ class="animation-figure" } + +=== "<5>" + ![그래프 깊이 우선 순회 5단계](graph_traversal.assets/graph_dfs_step5.png){ class="animation-figure" } + +=== "<6>" + ![그래프 깊이 우선 순회 6단계](graph_traversal.assets/graph_dfs_step6.png){ class="animation-figure" } + +=== "<7>" + ![그래프 깊이 우선 순회 7단계](graph_traversal.assets/graph_dfs_step7.png){ class="animation-figure" } + +=== "<8>" + ![그래프 깊이 우선 순회 8단계](graph_traversal.assets/graph_dfs_step8.png){ class="animation-figure" } + +=== "<9>" + ![그래프 깊이 우선 순회 9단계](graph_traversal.assets/graph_dfs_step9.png){ class="animation-figure" } + +=== "<10>" + ![그래프 깊이 우선 순회 10단계](graph_traversal.assets/graph_dfs_step10.png){ class="animation-figure" } + +=== "<11>" + ![그래프 깊이 우선 순회 11단계](graph_traversal.assets/graph_dfs_step11.png){ class="animation-figure" } + +

그림 9-12   그래프의 깊이 우선 순회 단계

+ +!!! question "깊이 우선 순회 순서는 유일한가?" + + 너비 우선 순회와 마찬가지로 깊이 우선 순회 순서도 유일하지 않다. 한 정점에서 어느 방향을 먼저 탐색해도 되므로, 인접 정점의 순서를 자유롭게 바꾸어도 모두 깊이 우선 순회에 해당한다. + + 트리 순회를 예로 들면 '루트 $\rightarrow$ 왼쪽 $\rightarrow$ 오른쪽', '왼쪽 $\rightarrow$ 루트 $\rightarrow$ 오른쪽', '왼쪽 $\rightarrow$ 오른쪽 $\rightarrow$ 루트'는 각각 전위, 중위, 후위 순회에 해당한다. 세 방식은 방문 우선순위가 다르지만 모두 깊이 우선 순회다. + +### 2.   복잡도 분석 + +**시간 복잡도**: 모든 정점을 $1$ 번씩 방문하는 데 $O(|V|)$ 시간이 걸리고, 모든 간선을 $2$ 번씩 방문하는 데 $O(2|E|)$ 시간이 걸린다. 전체 시간은 $O(|V| + |E|)$이다. + +**공간 복잡도**: 리스트 `res`와 해시 집합 `visited`의 정점 수는 각각 최대 $|V|$이고, 재귀 깊이도 최대 $|V|$이므로 $O(|V|)$ 공간을 사용한다. diff --git a/ko/docs/chapter_graph/index.md b/ko/docs/chapter_graph/index.md new file mode 100644 index 000000000..ed2c22809 --- /dev/null +++ b/ko/docs/chapter_graph/index.md @@ -0,0 +1,22 @@ +--- +comments: true +icon: material/graphql +--- + +# 제 9 장   그래프 + +![그래프](../assets/covers/chapter_graph.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 삶이라는 여정에서 우리는 하나하나의 노드처럼, 보이지 않는 수많은 간선으로 연결되어 있다. + + 만남과 헤어짐은 매번 이 거대한 네트워크 그래프에 저마다의 흔적을 남긴다. + +## 이 장의 내용 + +- [9.1   그래프](graph.md) +- [9.2   그래프의 기본 연산](graph_operations.md) +- [9.3   그래프 순회](graph_traversal.md) +- [9.4   요약](summary.md) +- [9.5   연습 문제](exercises.md) diff --git a/ko/docs/chapter_graph/summary.md b/ko/docs/chapter_graph/summary.md new file mode 100644 index 000000000..7ad6cebb6 --- /dev/null +++ b/ko/docs/chapter_graph/summary.md @@ -0,0 +1,35 @@ +--- +comments: true +--- + +# 9.4   요약 + +### 1.   핵심 정리 + +- 그래프는 정점과 간선으로 이루어지며, 정점 집합과 간선 집합으로 표현할 수 있다. +- 선형 관계(연결 리스트)와 분할 정복 관계(트리)에 비해 네트워크 관계(그래프)는 자유도가 높아 더 복잡하다. +- 방향 그래프의 간선에는 방향이 있고, 연결 그래프에서는 모든 정점에 도달할 수 있으며, 가중 그래프의 각 간선에는 가중치 변수가 있다. +- 인접 행렬은 행렬로 그래프를 표현한다. 각 행(열)은 정점 하나를, 행렬 원소는 간선을 나타내며, $1$ 또는 $0$으로 두 정점 사이에 간선이 있는지 표시한다. 인접 행렬은 추가, 삭제, 조회, 수정 연산이 매우 효율적이지만 공간을 많이 차지한다. +- 인접 리스트는 여러 연결 리스트로 그래프를 표현한다. $i$ 번째 연결 리스트는 정점 $i$에 대응하고, 해당 정점의 모든 인접 정점을 저장한다. 인접 행렬보다 공간을 절약하지만, 간선을 찾으려면 연결 리스트를 순회해야 하므로 시간 효율이 낮다. +- 인접 리스트 안의 연결 리스트가 너무 길면 레드-블랙 트리나 해시 테이블로 바꾸어 조회 효율을 높일 수 있다. +- 알고리즘의 관점에서 보면 인접 행렬은 '공간을 더 써서 시간을 절약'하고, 인접 리스트는 '시간을 더 써서 공간을 절약'한다. +- 그래프는 소셜 네트워크나 지하철 노선 등 다양한 현실 시스템을 모델링하는 데 사용할 수 있다. +- 트리는 그래프의 특수한 경우이며, 트리 순회도 그래프 순회의 특수한 경우다. +- 그래프의 너비 우선 순회는 가까운 곳에서 먼 곳으로 한 층씩 확장하는 탐색 방식이며, 보통 큐로 구현한다. +- 그래프의 깊이 우선 순회는 먼저 끝까지 나아가고 더 갈 곳이 없으면 되돌아오는 탐색 방식이며, 보통 재귀로 구현한다. + +### 2.   Q & A + +**Q**: 경로는 정점의 순서로 정의하는가, 간선의 순서로 정의하는가? + +위키백과의 언어별 정의는 서로 다르다. 영어판에서는 '경로는 간선의 순서'라고 하고, 중국어판에서는 '경로는 정점의 순서'라고 한다. 영어판 원문을 옮기면 다음과 같다. '그래프 이론에서 그래프의 경로는 정점의 나열을 연결하는 간선의 유한하거나 무한한 나열이다.' + +이 절에서는 경로를 정점의 순서가 아니라 간선의 순서로 본다. 두 정점 사이에 여러 간선이 연결될 수 있으며, 이때 각 간선은 각각 하나의 경로에 해당하기 때문이다. + +**Q**: 비연결 그래프에는 순회할 수 없는 정점이 있는가? + +비연결 그래프에서는 한 정점에서 출발했을 때 도달할 수 없는 정점이 하나 이상 존재한다. 그래프의 모든 연결 요소를 순회하려면 시작점을 여러 개 설정해야 한다. + +**Q**: 인접 리스트에서 '해당 정점과 연결된 모든 정점'의 순서에 제약이 있는가? + +순서는 자유롭다. 하지만 실제 응용에서는 지정한 규칙에 따라 정렬해야 할 수도 있다. 예를 들어 정점이 추가된 순서나 정점 값의 크기순으로 정렬하면, '특정 기준에서 최댓값이나 최솟값을 가진' 정점을 빠르게 찾는 데 도움이 된다. diff --git a/ko/docs/chapter_greedy/exercises.md b/ko/docs/chapter_greedy/exercises.md new file mode 100644 index 000000000..1e88db69a --- /dev/null +++ b/ko/docs/chapter_greedy/exercises.md @@ -0,0 +1,87 @@ +--- +comments: true +--- + +# 15.6   연습 문제 + +## 15.6.1   개념 확인 + +### 1.   매번 가장 큰 동전을 고르면 항상 최선인가 + +동전의 액면가는 `[1, 7, 10]`이며, 목표 금액은 14다. + + + +1. ‘남은 금액을 넘지 않는 가장 큰 액면가의 동전을 매번 고른다’는 규칙을 실행하고 선택한 동전을 적어 보자. +2. 동전을 더 적게 사용하는 방법이 있는가? 있으면 하나를 적고, 없으면 이유를 설명하라. +3. 이 결과로 해당 탐욕 전략이 어떤 동전 액면가에서든 올바르다고 할 수 있는가? + +??? success "참고 답안" + + 1. 탐욕 방식은 `10 + 1 + 1 + 1 + 1`을 차례대로 선택하여 총 5개의 동전을 사용한다. + + 2. 더 적게 사용하는 방법이 있다. `7 + 7`이면 동전 2개만 필요하다. + + 3. 그럴 수 없다. 이 반례는 임의의 동전 액면가에서 현재 고를 수 있는 가장 큰 액면가를 매번 선택한다고 해서 반드시 최소 동전 개수를 얻는 것은 아님을 보여 준다. + 당장 가장 큰 것을 선택하면 이후에 더 좋은 조합을 만들 기회를 잃을 수 있다. + +### 2.   배낭에 어떤 물건을 먼저 넣어야 하는가 + +용량이 4킬로그램인 배낭에 다음 물건을 넣을 수 있다. 물건의 일부만 넣어도 되며, +얻는 가치는 넣은 무게에 비례한다. + +- 물건 A: 무게 4킬로그램, 가치 20 +- 물건 B: 무게 3킬로그램, 가치 18 + + + +1. 두 물건의 킬로그램당 가치는 각각 얼마인가? 어느 물건을 먼저 넣어야 하는가? +2. 분할 가능한 배낭 문제의 탐욕 전략으로 배낭을 가득 채우면 최종 가치는 얼마인가? +3. 물건을 나눌 수 있고 배낭이 총무게를 제한하는 상황에서는 물건을 고를 때 총가치와 킬로그램당 가치 중 무엇을 비교해야 하는가? 이유도 설명하라. + +??? success "참고 답안" + + 1. A의 킬로그램당 가치는 `20 ÷ 4 = 5`, B의 킬로그램당 가치는 `18 ÷ 3 = 6`이다. + 따라서 단위 무게당 가치가 더 높은 B를 먼저 넣어야 한다. + + 2. 먼저 B를 전부 넣으면 3킬로그램을 차지하고 가치 18을 얻는다. 남은 용량 1킬로그램에는 + A를 1킬로그램 넣어 가치 5를 얻는다. 최종 가치는 `18 + 5 = 23`이다. + + 3. 배낭은 총무게를 제한하고 물건을 나눌 수 있으므로 단위 무게당 가치를 비교해야 한다. + A는 총가치가 더 높지만 킬로그램당 가치는 B보다 낮다. A부터 넣어 배낭을 채우면 가치 20밖에 얻지 못한다. + +### 3.   두 포인터를 다음에 어떻게 움직여야 하는가 + +칸막이 높이가 `[1, 8, 6, 2, 5]`일 때 양 끝에서 시작하는 두 포인터로 최대 용량을 찾는다. +용량은 ‘두 칸막이 중 더 낮은 높이 × 두 칸막이의 인덱스 차이’다. + + + +1. 처음에는 왼쪽 포인터가 인덱스 0, 오른쪽 포인터가 인덱스 4에 있다. 현재 용량은 얼마인가? 다음에는 어느 포인터를 움직여야 하는가? +2. 1번에서 선택한 대로 한 번 움직이면 두 포인터는 각각 어느 인덱스에 있는가? 이때 용량은 얼마이며, 다음에는 어느 포인터를 움직여야 하는가? +3. 현재 두 칸막이에서 더 낮은 쪽의 포인터를 움직일 수도 있고, 더 높은 쪽의 포인터를 움직일 수도 있다. 어느 쪽을 움직여야 더 큰 용량을 얻을 가능성이 남는가? 이유는 무엇인가? + +??? success "참고 답안" + + 1. 현재 용량은 `min(1, 5) × (4 - 0) = 4`다. 왼쪽 칸막이가 더 낮으므로 왼쪽 포인터를 움직인다. + + 2. 왼쪽 포인터를 움직이면 두 포인터는 각각 인덱스 1과 4에 있다. 현재 용량은 + `min(8, 5) × (4 - 1) = 15`다. 오른쪽 칸막이가 더 낮으므로 다음에는 오른쪽 포인터를 움직인다. + + 3. 더 낮은 칸막이 쪽의 포인터를 움직여야 더 큰 용량을 얻을 가능성이 남는다. 더 높은 칸막이를 움직이면 두 칸막이 사이의 거리는 반드시 줄어드는 반면, 용기의 높이는 여전히 움직이지 않은 낮은 칸막이에 의해 제한되므로 + 그대로이거나 더 낮아질 뿐이다. 따라서 용량은 움직이기 전보다 커질 수 없다. 낮은 칸막이를 움직여야만 더 높은 칸막이를 만날 기회가 생긴다. + +## 15.6.2   프로그래밍 연습 + +### 1.   분할 가능한 배낭 문제 + +길이가 같은 배열 `wgt`와 `val`이 주어진다. `wgt[i] > 0`, `val[i] >= 0`이며 배낭 용량은 `cap >= 0`이다. +각 종류의 물건은 하나씩만 있지만, 그중 일부만 넣어도 된다. +얻는 가치는 넣은 무게가 해당 물건의 총무게에서 차지하는 비율로 계산한다. 탐욕 알고리즘을 사용하여 +배낭에서 얻을 수 있는 최대 총가치를 실수로 반환하라. + +??? tip "풀이 힌트" + + 1. 먼저 각 물건의 단위 무게당 가치 val[i] / wgt[i]를 계산한다. 나눗셈 결과의 소수 부분을 유지해야 한다. + 2. 단위 무게당 가치가 높은 물건부터 배낭에 넣는다. + 3. 남은 용량이 현재 물건의 무게보다 작으면 배낭을 정확히 채우는 만큼만 넣고 끝낸다. diff --git a/ko/docs/chapter_greedy/fractional_knapsack_problem.md b/ko/docs/chapter_greedy/fractional_knapsack_problem.md new file mode 100644 index 000000000..9650f6b50 --- /dev/null +++ b/ko/docs/chapter_greedy/fractional_knapsack_problem.md @@ -0,0 +1,557 @@ +--- +comments: true +--- + +# 15.2   분할 가능한 배낭 문제 + +!!! question "문제" + + 물건 $n$ 개와 용량이 $cap$인 배낭이 주어진다. $i$ 번째 물건의 무게는 $wgt[i-1]$, 가치는 $val[i-1]$이다. 각 물건은 한 번만 선택할 수 있지만, **일부만 선택할 수 있으며 가치는 선택한 무게의 비율에 따라 계산한다**. 용량 제한 안에서 배낭에 담을 수 있는 최대 가치를 구하라. 예시는 아래 그림(15-3)과 같다. + +![분할 가능한 배낭 문제 예시 데이터](fractional_knapsack_problem.assets/fractional_knapsack_example.png){ class="animation-figure" } + +

그림 15-3   분할 가능한 배낭 문제 예시 데이터

+ +분할 가능한 배낭 문제는 전체적으로 0-1 배낭 문제와 매우 비슷하다. 상태는 현재 물건 $i$와 용량 $c$를 포함하며, 목표는 용량 제한 안에서 최대 가치를 구하는 것이다. + +차이점은 물건의 일부만 선택할 수 있다는 점이다. 아래 그림(15-4)과 같이 **물건을 원하는 대로 나누고 무게 비율에 따라 그 가치를 계산할 수 있다**. + +1. 물건 $i$의 단위 무게당 가치는 $val[i-1] / wgt[i-1]$이며, 줄여서 단위 가치라고 한다. +2. 물건 $i$의 일부를 무게 $w$만큼 넣으면 배낭의 가치는 $w \times val[i-1] / wgt[i-1]$만큼 늘어난다. + +![물건의 단위 무게당 가치](fractional_knapsack_problem.assets/fractional_knapsack_unit_value.png){ class="animation-figure" } + +

그림 15-4   물건의 단위 무게당 가치

+ +### 1.   탐욕 전략 결정 + +배낭 안의 총가치를 최대화하는 것은 **본질적으로 단위 무게당 물건 가치를 최대화하는 것**이다. 여기서 아래 그림(15-5)의 탐욕 전략을 도출할 수 있다. + +1. 물건을 단위 가치가 높은 순서로 정렬한다. +2. 모든 물건을 순회하며 **매번 단위 가치가 가장 높은 물건을 탐욕적으로 선택한다**. +3. 남은 용량이 부족하면 현재 물건의 일부로 배낭을 가득 채운다. + +![분할 가능한 배낭 문제의 탐욕 전략](fractional_knapsack_problem.assets/fractional_knapsack_greedy_strategy.png){ class="animation-figure" } + +

그림 15-5   분할 가능한 배낭 문제의 탐욕 전략

+ +### 2.   코드 구현 + +단위 가치순으로 정렬하기 쉽도록 물건 클래스 `Item`을 만든다. 탐욕적 선택을 반복하다가 배낭이 가득 차면 반복문을 빠져나와 해를 반환한다. + +=== "Python" + + ```python title="fractional_knapsack.py" + class Item: + """물건""" + + def __init__(self, w: int, v: int): + self.w = w # 물건의 무게 + self.v = v # 물건의 가치 + + def fractional_knapsack(wgt: list[int], val: list[int], cap: int) -> int: + """분할 가능한 배낭: 탐욕""" + # 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + items = [Item(w, v) for w, v in zip(wgt, val)] + # 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + items.sort(key=lambda item: item.v / item.w, reverse=True) + # 탐욕적 선택을 반복한다 + res = 0 + for item in items: + if item.w <= cap: + # 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v + cap -= item.w + else: + # 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += (item.v / item.w) * cap + # 남은 용량이 없으므로 반복문을 종료한다 + break + return res + ``` + +=== "C++" + + ```cpp title="fractional_knapsack.cpp" + /* 물건 */ + class Item { + public: + int w; // 물건의 무게 + int v; // 물건의 가치 + + Item(int w, int v) : w(w), v(v) { + } + }; + + /* 분할 가능한 배낭: 탐욕 */ + double fractionalKnapsack(vector &wgt, vector &val, int cap) { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + vector items; + for (int i = 0; i < wgt.size(); i++) { + items.push_back(Item(wgt[i], val[i])); + } + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + sort(items.begin(), items.end(), [](Item &a, Item &b) { return (double)a.v / a.w > (double)b.v / b.w; }); + // 탐욕적 선택을 반복한다 + double res = 0; + for (auto &item : items) { + if (item.w <= cap) { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v; + cap -= item.w; + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += (double)item.v / item.w * cap; + // 남은 용량이 없으므로 반복문을 종료한다 + break; + } + } + return res; + } + ``` + +=== "Java" + + ```java title="fractional_knapsack.java" + /* 물건 */ + class Item { + int w; // 물건의 무게 + int v; // 물건의 가치 + + public Item(int w, int v) { + this.w = w; + this.v = v; + } + } + + /* 분할 가능한 배낭: 탐욕 */ + double fractionalKnapsack(int[] wgt, int[] val, int cap) { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + Item[] items = new Item[wgt.length]; + for (int i = 0; i < wgt.length; i++) { + items[i] = new Item(wgt[i], val[i]); + } + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + Arrays.sort(items, Comparator.comparingDouble(item -> -((double) item.v / item.w))); + // 탐욕적 선택을 반복한다 + double res = 0; + for (Item item : items) { + if (item.w <= cap) { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v; + cap -= item.w; + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += (double) item.v / item.w * cap; + // 남은 용량이 없으므로 반복문을 종료한다 + break; + } + } + return res; + } + ``` + +=== "C#" + + ```csharp title="fractional_knapsack.cs" + /* 물건 */ + class Item(int w, int v) { + public int w = w; // 물건의 무게 + public int v = v; // 물건의 가치 + } + + /* 분할 가능한 배낭: 탐욕 */ + double FractionalKnapsack(int[] wgt, int[] val, int cap) { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + Item[] items = new Item[wgt.Length]; + for (int i = 0; i < wgt.Length; i++) { + items[i] = new Item(wgt[i], val[i]); + } + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + Array.Sort(items, (x, y) => (y.v / y.w).CompareTo(x.v / x.w)); + // 탐욕적 선택을 반복한다 + double res = 0; + foreach (Item item in items) { + if (item.w <= cap) { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v; + cap -= item.w; + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += (double)item.v / item.w * cap; + // 남은 용량이 없으므로 반복문을 종료한다 + break; + } + } + return res; + } + ``` + +=== "Go" + + ```go title="fractional_knapsack.go" + /* 물건 */ + type Item struct { + w int // 물건의 무게 + v int // 물건의 가치 + } + + /* 분할 가능한 배낭: 탐욕 */ + func fractionalKnapsack(wgt []int, val []int, cap int) float64 { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + items := make([]Item, len(wgt)) + for i := 0; i < len(wgt); i++ { + items[i] = Item{wgt[i], val[i]} + } + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + sort.Slice(items, func(i, j int) bool { + return float64(items[i].v)/float64(items[i].w) > float64(items[j].v)/float64(items[j].w) + }) + // 탐욕적 선택을 반복한다 + res := 0.0 + for _, item := range items { + if item.w <= cap { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += float64(item.v) + cap -= item.w + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += float64(item.v) / float64(item.w) * float64(cap) + // 남은 용량이 없으므로 반복문을 종료한다 + break + } + } + return res + } + ``` + +=== "Swift" + + ```swift title="fractional_knapsack.swift" + /* 물건 */ + class Item { + var w: Int // 물건의 무게 + var v: Int // 물건의 가치 + + init(w: Int, v: Int) { + self.w = w + self.v = v + } + } + + /* 분할 가능한 배낭: 탐욕 */ + func fractionalKnapsack(wgt: [Int], val: [Int], cap: Int) -> Double { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + var items = zip(wgt, val).map { Item(w: $0, v: $1) } + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + items.sort { -(Double($0.v) / Double($0.w)) < -(Double($1.v) / Double($1.w)) } + // 탐욕적 선택을 반복한다 + var res = 0.0 + var cap = cap + for item in items { + if item.w <= cap { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += Double(item.v) + cap -= item.w + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += Double(item.v) / Double(item.w) * Double(cap) + // 남은 용량이 없으므로 반복문을 종료한다 + break + } + } + return res + } + ``` + +=== "JS" + + ```javascript title="fractional_knapsack.js" + /* 물건 */ + class Item { + constructor(w, v) { + this.w = w; // 물건의 무게 + this.v = v; // 물건의 가치 + } + } + + /* 분할 가능한 배낭: 탐욕 */ + function fractionalKnapsack(wgt, val, cap) { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + const items = wgt.map((w, i) => new Item(w, val[i])); + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + items.sort((a, b) => b.v / b.w - a.v / a.w); + // 탐욕적 선택을 반복한다 + let res = 0; + for (const item of items) { + if (item.w <= cap) { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v; + cap -= item.w; + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += (item.v / item.w) * cap; + // 남은 용량이 없으므로 반복문을 종료한다 + break; + } + } + return res; + } + ``` + +=== "TS" + + ```typescript title="fractional_knapsack.ts" + /* 물건 */ + class Item { + w: number; // 물건의 무게 + v: number; // 물건의 가치 + + constructor(w: number, v: number) { + this.w = w; + this.v = v; + } + } + + /* 분할 가능한 배낭: 탐욕 */ + function fractionalKnapsack(wgt: number[], val: number[], cap: number): number { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + const items: Item[] = wgt.map((w, i) => new Item(w, val[i])); + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + items.sort((a, b) => b.v / b.w - a.v / a.w); + // 탐욕적 선택을 반복한다 + let res = 0; + for (const item of items) { + if (item.w <= cap) { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v; + cap -= item.w; + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += (item.v / item.w) * cap; + // 남은 용량이 없으므로 반복문을 종료한다 + break; + } + } + return res; + } + ``` + +=== "Dart" + + ```dart title="fractional_knapsack.dart" + /* 물건 */ + class Item { + int w; // 물건의 무게 + int v; // 물건의 가치 + + Item(this.w, this.v); + } + + /* 분할 가능한 배낭: 탐욕 */ + double fractionalKnapsack(List wgt, List val, int cap) { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + List items = List.generate(wgt.length, (i) => Item(wgt[i], val[i])); + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + items.sort((a, b) => (b.v / b.w).compareTo(a.v / a.w)); + // 탐욕적 선택을 반복한다 + double res = 0; + for (Item item in items) { + if (item.w <= cap) { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v; + cap -= item.w; + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += item.v / item.w * cap; + // 남은 용량이 없으므로 반복문을 종료한다 + break; + } + } + return res; + } + ``` + +=== "Rust" + + ```rust title="fractional_knapsack.rs" + /* 물건 */ + struct Item { + w: i32, // 물건의 무게 + v: i32, // 물건의 가치 + } + + impl Item { + fn new(w: i32, v: i32) -> Self { + Self { w, v } + } + } + + /* 분할 가능한 배낭: 탐욕 */ + fn fractional_knapsack(wgt: &[i32], val: &[i32], mut cap: i32) -> f64 { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + let mut items = wgt + .iter() + .zip(val.iter()) + .map(|(&w, &v)| Item::new(w, v)) + .collect::>(); + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + items.sort_by(|a, b| { + (b.v as f64 / b.w as f64) + .partial_cmp(&(a.v as f64 / a.w as f64)) + .unwrap() + }); + // 탐욕적 선택을 반복한다 + let mut res = 0.0; + for item in &items { + if item.w <= cap { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v as f64; + cap -= item.w; + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += item.v as f64 / item.w as f64 * cap as f64; + // 남은 용량이 없으므로 반복문을 종료한다 + break; + } + } + res + } + ``` + +=== "C" + + ```c title="fractional_knapsack.c" + /* 물건 */ + typedef struct { + int w; // 물건의 무게 + int v; // 물건의 가치 + } Item; + + /* 분할 가능한 배낭: 탐욕 */ + float fractionalKnapsack(int wgt[], int val[], int itemCount, int cap) { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + Item *items = malloc(sizeof(Item) * itemCount); + for (int i = 0; i < itemCount; i++) { + items[i] = (Item){.w = wgt[i], .v = val[i]}; + } + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + qsort(items, (size_t)itemCount, sizeof(Item), sortByValueDensity); + // 탐욕적 선택을 반복한다 + float res = 0.0; + for (int i = 0; i < itemCount; i++) { + if (items[i].w <= cap) { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += items[i].v; + cap -= items[i].w; + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += (float)cap / items[i].w * items[i].v; + cap = 0; + break; + } + } + free(items); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="fractional_knapsack.kt" + /* 물건 */ + class Item( + val w: Int, // 물건 + val v: Int // 물건의 가치 + ) + + /* 분할 가능한 배낭: 탐욕 */ + fun fractionalKnapsack(wgt: IntArray, _val: IntArray, c: Int): Double { + // 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + var cap = c + val items = arrayOfNulls(wgt.size) + for (i in wgt.indices) { + items[i] = Item(wgt[i], _val[i]) + } + // 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + items.sortBy { item: Item? -> -(item!!.v.toDouble() / item.w) } + // 탐욕적 선택을 반복한다 + var res = 0.0 + for (item in items) { + if (item!!.w <= cap) { + // 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v + cap -= item.w + } else { + // 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += item.v.toDouble() / item.w * cap + // 남은 용량이 없으므로 반복문을 종료한다 + break + } + } + return res + } + ``` + +=== "Ruby" + + ```ruby title="fractional_knapsack.rb" + ### 물건 ### + class Item + attr_accessor :w # 물건의 무게 + attr_accessor :v # 물건의 가치 + + def initialize(w, v) + @w = w + @v = v + end + end + + ### 분할 가능한 배낭: 탐욕 ### + def fractional_knapsack(wgt, val, cap) + # 무게와 가치 두 속성을 갖는 물건의 리스트를 생성한다 + items = wgt.each_with_index.map { |w, i| Item.new(w, val[i]) } + # 단위 가치 item.v / item.w를 기준으로 내림차순 정렬한다 + items.sort! { |a, b| (b.v.to_f / b.w) <=> (a.v.to_f / a.w) } + # 탐욕적 선택을 반복한다 + res = 0 + for item in items + if item.w <= cap + # 남은 용량이 충분하면 현재 물건을 통째로 배낭에 넣는다 + res += item.v + cap -= item.w + else + # 남은 용량이 부족하면 현재 물건의 일부를 배낭에 넣는다 + res += (item.v.to_f / item.w) * cap + # 남은 용량이 없으므로 반복문을 종료한다 + break + end + end + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +내장 정렬 알고리즘의 시간 복잡도는 보통 $O(n \log n)$이고, 공간 복잡도는 언어의 구체적인 구현에 따라 대체로 $O(\log n)$ 또는 $O(n)$이다. + +정렬을 제외하면 최악의 경우 물건 리스트 전체를 순회해야 하므로 **시간 복잡도는 $O(n)$이다**. 여기서 $n$은 물건 수다. + +`Item` 객체 리스트를 초기화하므로 **공간 복잡도는 $O(n)$이다**. + +### 3.   정확성 증명 + +귀류법을 사용하자. 물건 $x$의 단위 가치가 가장 높고, 어떤 알고리즘으로 구한 최대 가치가 `res`인데 그 해에 $x$가 없다고 가정한다. + +배낭에서 임의의 물건을 단위 무게만큼 꺼내고 같은 무게의 물건 $x$로 바꾼다. $x$의 단위 가치가 가장 높으므로 바꾼 뒤의 총가치는 반드시 `res`보다 커진다. **이는 `res`가 최적해라는 가정에 모순이므로 최적해에는 물건 $x$가 포함되어야 한다**. + +그 해의 다른 물건에도 같은 모순을 구성할 수 있다. 즉, **단위 가치가 더 높은 물건이 항상 더 좋은 선택**이며, 탐욕 전략이 유효함을 알 수 있다. + +아래 그림(15-6)과 같이 물건 무게를 가로축, 단위 가치를 세로축으로 보면, 분할 가능한 배낭 문제는 '제한된 가로축 구간에서 최대 넓이를 구하는' 문제로 바뀐다. 이 비유는 탐욕 전략의 유효성을 기하학적으로 이해하는 데 도움이 된다. + +![분할 가능한 배낭 문제의 기하학적 표현](fractional_knapsack_problem.assets/fractional_knapsack_area_chart.png){ class="animation-figure" } + +

그림 15-6   분할 가능한 배낭 문제의 기하학적 표현

diff --git a/ko/docs/chapter_greedy/greedy_algorithm.md b/ko/docs/chapter_greedy/greedy_algorithm.md new file mode 100644 index 000000000..e0629df7f --- /dev/null +++ b/ko/docs/chapter_greedy/greedy_algorithm.md @@ -0,0 +1,408 @@ +--- +comments: true +--- + +# 15.1   탐욕 알고리즘 + +탐욕 알고리즘(greedy algorithm)은 최적화 문제를 푸는 대표적인 알고리즘이다. 매 결정 단계에서 현재 가장 좋아 보이는 선택, 즉 국소적으로 최적인 결정을 탐욕적으로 내려 전역 최적해를 얻으려는 것이 기본 아이디어다. 간결하고 효율적이어서 많은 실제 문제에 널리 쓰인다. + +탐욕 알고리즘과 동적 계획법은 모두 최적화 문제에 자주 사용한다. 최적 부분 구조에 의존한다는 공통점 등이 있지만 작동 원리는 다르다. + +- 동적 계획법은 이전 단계의 모든 결정을 바탕으로 현재 결정을 고려하고, 과거 부분 문제의 해로 현재 부분 문제의 해를 구성한다. +- 탐욕 알고리즘은 과거 결정을 고려하지 않고 계속 앞으로 나아가며 탐욕적으로 선택한다. 문제의 범위를 점차 줄여 해결될 때까지 진행한다. + +먼저 '동전 교환' 예제로 탐욕 알고리즘의 원리를 알아보자. '무한 배낭 문제' 절에서 소개한 문제라 익숙할 것이다. + +!!! question "문제" + + 동전 $n$ 종류가 주어지며 $i$ 번째 동전의 액면가는 $coins[i - 1]$이고 목표 금액은 $amt$다. 각 종류의 동전을 반복해서 선택할 수 있을 때 목표 금액을 만드는 최소 동전 수를 구하라. 만들 수 없다면 $-1$을 반환하라. + +이 문제에서 사용하는 탐욕 전략은 아래 그림(15-1)과 같다. 주어진 목표 금액에 대해 **그 금액을 넘지 않으면서 가장 가까운 액면가의 동전을 탐욕적으로 고르고**, 목표 금액을 만들 때까지 반복한다. + +![동전 교환의 탐욕 전략](greedy_algorithm.assets/coin_change_greedy_strategy.png){ class="animation-figure" } + +

그림 15-1   동전 교환의 탐욕 전략

+ +구현 코드는 다음과 같다. + +=== "Python" + + ```python title="coin_change_greedy.py" + def coin_change_greedy(coins: list[int], amt: int) -> int: + """동전 교환: 탐욕""" + # coins 리스트가 정렬되어 있다고 가정한다 + i = len(coins) - 1 + count = 0 + # 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while amt > 0: + # 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while i > 0 and coins[i] > amt: + i -= 1 + # coins[i]를 선택한다 + amt -= coins[i] + count += 1 + # 가능한 방법을 찾지 못하면 -1을 반환한다 + return count if amt == 0 else -1 + ``` + +=== "C++" + + ```cpp title="coin_change_greedy.cpp" + /* 동전 교환: 탐욕 */ + int coinChangeGreedy(vector &coins, int amt) { + // coins 리스트가 정렬되어 있다고 가정한다 + int i = coins.size() - 1; + int count = 0; + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while (amt > 0) { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while (i > 0 && coins[i] > amt) { + i--; + } + // coins[i]를 선택한다 + amt -= coins[i]; + count++; + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + return amt == 0 ? count : -1; + } + ``` + +=== "Java" + + ```java title="coin_change_greedy.java" + /* 동전 교환: 탐욕 */ + int coinChangeGreedy(int[] coins, int amt) { + // coins 리스트가 정렬되어 있다고 가정한다 + int i = coins.length - 1; + int count = 0; + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while (amt > 0) { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while (i > 0 && coins[i] > amt) { + i--; + } + // coins[i]를 선택한다 + amt -= coins[i]; + count++; + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + return amt == 0 ? count : -1; + } + ``` + +=== "C#" + + ```csharp title="coin_change_greedy.cs" + /* 동전 교환: 탐욕 */ + int CoinChangeGreedy(int[] coins, int amt) { + // coins 리스트가 정렬되어 있다고 가정한다 + int i = coins.Length - 1; + int count = 0; + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while (amt > 0) { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while (i > 0 && coins[i] > amt) { + i--; + } + // coins[i]를 선택한다 + amt -= coins[i]; + count++; + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + return amt == 0 ? count : -1; + } + ``` + +=== "Go" + + ```go title="coin_change_greedy.go" + /* 동전 교환: 탐욕 */ + func coinChangeGreedy(coins []int, amt int) int { + // coins 리스트가 정렬되어 있다고 가정한다 + i := len(coins) - 1 + count := 0 + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + for amt > 0 { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + for i > 0 && coins[i] > amt { + i-- + } + // coins[i]를 선택한다 + amt -= coins[i] + count++ + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + if amt != 0 { + return -1 + } + return count + } + ``` + +=== "Swift" + + ```swift title="coin_change_greedy.swift" + /* 동전 교환: 탐욕 */ + func coinChangeGreedy(coins: [Int], amt: Int) -> Int { + // coins 리스트가 정렬되어 있다고 가정한다 + var i = coins.count - 1 + var count = 0 + var amt = amt + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while amt > 0 { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while i > 0 && coins[i] > amt { + i -= 1 + } + // coins[i]를 선택한다 + amt -= coins[i] + count += 1 + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + return amt == 0 ? count : -1 + } + ``` + +=== "JS" + + ```javascript title="coin_change_greedy.js" + /* 동전 교환: 탐욕 */ + function coinChangeGreedy(coins, amt) { + // coins 배열이 정렬되어 있다고 가정한다 + let i = coins.length - 1; + let count = 0; + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while (amt > 0) { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while (i > 0 && coins[i] > amt) { + i--; + } + // coins[i]를 선택한다 + amt -= coins[i]; + count++; + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + return amt === 0 ? count : -1; + } + ``` + +=== "TS" + + ```typescript title="coin_change_greedy.ts" + /* 동전 교환: 탐욕 */ + function coinChangeGreedy(coins: number[], amt: number): number { + // coins 배열이 정렬되어 있다고 가정한다 + let i = coins.length - 1; + let count = 0; + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while (amt > 0) { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while (i > 0 && coins[i] > amt) { + i--; + } + // coins[i]를 선택한다 + amt -= coins[i]; + count++; + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + return amt === 0 ? count : -1; + } + ``` + +=== "Dart" + + ```dart title="coin_change_greedy.dart" + /* 동전 교환: 탐욕 */ + int coinChangeGreedy(List coins, int amt) { + // coins 리스트가 정렬되어 있다고 가정한다 + int i = coins.length - 1; + int count = 0; + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while (amt > 0) { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while (i > 0 && coins[i] > amt) { + i--; + } + // coins[i]를 선택한다 + amt -= coins[i]; + count++; + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + return amt == 0 ? count : -1; + } + ``` + +=== "Rust" + + ```rust title="coin_change_greedy.rs" + /* 동전 교환: 탐욕 */ + fn coin_change_greedy(coins: &[i32], mut amt: i32) -> i32 { + // coins 리스트가 정렬되어 있다고 가정한다 + let mut i = coins.len() - 1; + let mut count = 0; + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while amt > 0 { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while i > 0 && coins[i] > amt { + i -= 1; + } + // coins[i]를 선택한다 + amt -= coins[i]; + count += 1; + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + if amt == 0 { + count + } else { + -1 + } + } + ``` + +=== "C" + + ```c title="coin_change_greedy.c" + /* 동전 교환: 탐욕 */ + int coinChangeGreedy(int *coins, int size, int amt) { + // coins 리스트가 정렬되어 있다고 가정한다 + int i = size - 1; + int count = 0; + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while (amt > 0) { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while (i > 0 && coins[i] > amt) { + i--; + } + // coins[i]를 선택한다 + amt -= coins[i]; + count++; + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + return amt == 0 ? count : -1; + } + ``` + +=== "Kotlin" + + ```kotlin title="coin_change_greedy.kt" + /* 동전 교환: 탐욕 */ + fun coinChangeGreedy(coins: IntArray, amt: Int): Int { + // coins 리스트가 정렬되어 있다고 가정한다 + var am = amt + var i = coins.size - 1 + var count = 0 + // 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while (am > 0) { + // 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while (i > 0 && coins[i] > am) { + i-- + } + // coins[i]를 선택한다 + am -= coins[i] + count++ + } + // 가능한 방법을 찾지 못하면 -1을 반환한다 + return if (am == 0) count else -1 + } + ``` + +=== "Ruby" + + ```ruby title="coin_change_greedy.rb" + ### 동전 교환: 탐욕 ### + def coin_change_greedy(coins, amt) + # coins 리스트가 정렬되어 있다고 가정한다 + i = coins.length - 1 + count = 0 + # 남은 금액이 없을 때까지 탐욕적 선택을 반복한다 + while amt > 0 + # 남은 금액보다 작으면서 가장 가까운 금액의 동전을 찾는다 + while i > 0 && coins[i] > amt + i -= 1 + end + # coins[i]를 선택한다 + amt -= coins[i] + count += 1 + end + # 가능한 방법을 찾지 못하면 -1을 반환한다 + amt == 0 ? count : -1 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +'정말 간결하다!'라는 감탄이 나올 만하다. 탐욕 알고리즘은 약 열 줄의 코드만으로 동전 교환 문제를 해결했다. + +## 15.1.1   탐욕 알고리즘의 장점과 한계 + +**탐욕 알고리즘은 연산이 직접적이고 구현이 간단할 뿐 아니라 보통 효율도 매우 높다**. 위 코드에서 최소 액면가를 $\min(coins)$라고 하면 탐욕적 선택은 최대 $amt / \min(coins)$ 번 반복하므로 시간 복잡도는 $O(amt / \min(coins))$이다. 동적 계획법의 $O(n \times amt)$보다 한 차수 낮다. + +하지만 **일부 동전 액면가 조합에서는 최적해를 찾지 못한다**. 아래 그림(15-2)은 두 예를 보여 준다. + +- **성립 예 $coins = [1, 5, 10, 20, 50, 100]$**: 이 동전 조합에서는 어떤 $amt$에 대해서도 탐욕 알고리즘이 최적해를 찾는다. +- **반례 $coins = [1, 20, 50]$**: $amt = 60$이면 탐욕 알고리즘은 $50 + 1 \times 10$으로 동전 $11$ 개를 고른다. 하지만 동적 계획법은 $20 + 20 + 20$이라는 최적해를 찾아 $3$ 개만 사용한다. +- **반례 $coins = [1, 49, 50]$**: $amt = 98$이면 탐욕 알고리즘은 $50 + 1 \times 48$로 동전 $49$ 개를 고른다. 하지만 동적 계획법은 $49 + 49$라는 최적해를 찾아 $2$ 개만 사용한다. + +![탐욕 알고리즘이 최적해를 찾지 못하는 예](greedy_algorithm.assets/coin_change_greedy_vs_dp.png){ class="animation-figure" } + +

그림 15-2   탐욕 알고리즘이 최적해를 찾지 못하는 예

+ +즉, 동전 교환에서 탐욕 알고리즘은 전역 최적해를 보장하지 못하고 매우 나쁜 해를 찾을 수도 있다. 이 문제는 동적 계획법으로 푸는 편이 더 적합하다. + +일반적으로 탐욕 알고리즘을 적용하는 상황은 다음 두 가지다. + +1. **최적해를 보장할 수 있다**: 보통 백트래킹이나 동적 계획법보다 효율적이므로 이런 경우에는 탐욕 알고리즘이 가장 좋은 선택인 경우가 많다. +2. **최적해에 가까운 해를 찾을 수 있다**: 이 경우에도 탐욕 알고리즘을 쓸 수 있다. 복잡한 문제에서는 전역 최적해를 찾기 매우 어려우므로, 효율적으로 차선의 해를 찾는 것만으로도 유용하다. + +## 15.1.2   탐욕 알고리즘의 특성 + +그렇다면 어떤 문제가 탐욕 알고리즘에 적합할까? 다시 말해 어떤 경우에 최적해를 보장할 수 있을까? + +탐욕 알고리즘은 동적 계획법보다 적용 조건이 엄격하며, 주로 다음 두 성질에 주목한다. + +- **탐욕적 선택 속성**: 국소 최적 선택이 항상 전역 최적해로 이어져야만 탐욕 알고리즘이 최적해를 보장할 수 있다. +- **최적 부분 구조**: 원래 문제의 최적해가 부분 문제의 최적해를 포함한다. + +최적 부분 구조는 '동적 계획법' 장에서 설명했으므로 반복하지 않는다. 일부 문제는 최적 부분 구조가 뚜렷하지 않아도 탐욕 알고리즘으로 해결할 수 있다는 점을 알아 두자. + +여기서는 탐욕적 선택 속성을 판단하는 방법을 주로 살펴본다. 설명은 간단해 보이지만 **많은 문제에서 이 속성을 증명하기는 쉽지 않다**. + +예를 들어 동전 교환에서는 반례로 탐욕적 선택 속성을 부정하기는 쉽지만 증명하기는 어렵다. **어떤 조건을 만족하는 동전 조합을 탐욕 알고리즘으로 풀 수 있는가**라는 질문에는 직관이나 사례를 바탕으로 애매한 답을 내놓기 쉽고, 엄밀한 수학적 증명을 제시하기는 어렵다. + +!!! quote "참고 문헌" + + 어떤 동전 조합에 대해 탐욕 알고리즘이 모든 금액의 최적해를 찾을 수 있는지 판단하는 $O(n^3)$ 알고리즘을 제시한 논문이 있다. + + Pearson, D. A polynomial-time algorithm for the change-making problem[J]. Operations Research Letters, 2005, 33(3): 231-234. + +## 15.1.3   탐욕 알고리즘의 문제 해결 단계 + +탐욕 문제의 풀이 과정은 대체로 다음 세 단계다. + +1. **문제 분석**: 상태 정의, 최적화 목표, 제약 조건 등 문제의 특성을 정리하고 이해한다. 백트래킹과 동적 계획법에서도 필요한 단계다. +2. **탐욕 전략 결정**: 매 단계에서 어떻게 탐욕적으로 선택할지 정한다. 각 단계에서 문제 크기를 줄여 최종적으로 전체 문제를 해결할 수 있어야 한다. +3. **정확성 증명**: 보통 탐욕적 선택 속성과 최적 부분 구조를 증명해야 한다. 귀납법이나 귀류법 같은 수학적 증명이 필요할 수 있다. + +탐욕 전략을 정하는 것이 핵심 단계지만, 다음 이유로 쉽지 않을 수 있다. + +- **문제마다 탐욕 전략이 크게 다르다**. 많은 문제는 전략이 비교적 명확하여 대략적인 생각과 시도만으로 찾을 수 있다. 반면 복잡한 문제에서는 전략이 잘 드러나지 않아 문제 풀이 경험과 알고리즘 역량이 크게 요구된다. +- **일부 탐욕 전략은 그럴듯해 보이지만 함정이 있다**. 자신 있게 전략을 설계하고 코드를 작성해 제출해도 일부 테스트를 통과하지 못할 수 있다. 전략이 '부분적으로만 옳기' 때문이다. 앞에서 본 동전 교환이 대표적인 사례다. + +정확성을 보장하려면 탐욕 전략을 엄밀히 증명해야 하며, **보통 귀류법이나 수학적 귀납법을 사용한다**. + +하지만 정확성 증명 자체도 쉽지 않을 수 있다. 실마리가 없으면 보통 테스트 사례로 코드를 디버깅하면서 탐욕 전략을 조금씩 수정하고 검증한다. + +## 15.1.4   대표적인 탐욕 알고리즘 문제 + +탐욕 알고리즘은 탐욕적 선택 속성과 최적 부분 구조를 만족하는 최적화 문제에 자주 사용한다. 대표적인 예는 다음과 같다. + +- **동전 거스름돈 문제**: 일부 동전 조합에서는 탐욕 알고리즘이 항상 최적해를 얻는다. +- **구간 스케줄링 문제**: 각 작업이 일정 시간 구간에 걸쳐 진행될 때 가능한 한 많은 작업을 완료하는 것이 목표다. 매번 가장 일찍 끝나는 작업을 고르면 최적해를 얻을 수 있다. +- **분할 가능한 배낭 문제**: 물건들과 적재 용량이 주어졌을 때 총무게가 용량을 넘지 않으면서 총가치가 최대가 되도록 물건을 고른다. 매번 무게 대비 가치의 비율(가치 / 무게)이 가장 높은 물건을 고르면 일부 경우에 최적해를 얻을 수 있다. +- **주식 매매 문제**: 주가의 과거 기록이 주어졌을 때 여러 번 매매하여 최대 이익을 구한다. 단, 이미 주식을 보유하고 있으면 팔기 전에는 다시 살 수 없다. +- **허프먼 코딩**: 무손실 데이터 압축에 쓰이는 탐욕 알고리즘이다. 매번 빈도가 가장 낮은 두 노드를 합쳐 허프먼 트리를 만들면, 최종 트리의 가중 경로 길이(부호 길이)가 최소가 된다. +- **Dijkstra 알고리즘**: 모든 간선 가중치가 음이 아닌 그래프에서 주어진 시작 정점부터 나머지 정점까지의 최단 경로를 구하는 탐욕 알고리즘이다. diff --git a/ko/docs/chapter_greedy/index.md b/ko/docs/chapter_greedy/index.md new file mode 100644 index 000000000..fae6dc10e --- /dev/null +++ b/ko/docs/chapter_greedy/index.md @@ -0,0 +1,23 @@ +--- +comments: true +icon: material/head-heart-outline +--- + +# 제 15 장   탐욕 + +![탐욕](../assets/covers/chapter_greedy.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 해바라기는 태양을 따라 돌며 매 순간 자신의 성장 가능성을 최대한 추구한다. + + 탐욕 전략은 단순한 선택을 거듭하면서 차츰 최선의 답으로 나아간다. + +## 이 장의 내용 + +- [15.1   탐욕 알고리즘](greedy_algorithm.md) +- [15.2   분할 가능한 배낭 문제](fractional_knapsack_problem.md) +- [15.3   최대 용량 문제](max_capacity_problem.md) +- [15.4   정수 분할의 최대 곱 문제](max_product_cutting_problem.md) +- [15.5   요약](summary.md) +- [15.6   연습 문제](exercises.md) diff --git a/ko/docs/chapter_greedy/max_capacity_problem.md b/ko/docs/chapter_greedy/max_capacity_problem.md new file mode 100644 index 000000000..8a0c20369 --- /dev/null +++ b/ko/docs/chapter_greedy/max_capacity_problem.md @@ -0,0 +1,445 @@ +--- +comments: true +--- + +# 15.3   최대 용량 문제 + +!!! question "문제" + + 배열 $ht$의 각 원소는 수직 칸막이의 높이를 나타낸다. 배열에서 임의의 두 칸막이와 그 사이의 공간으로 용기를 만들 수 있다. + + 용량은 높이와 너비의 곱(넓이)이다. 높이는 더 낮은 칸막이로 결정되고, 너비는 두 칸막이의 배열 인덱스 차이다. + + 용량이 최대가 되도록 두 칸막이를 골라 최대 용량을 반환하라. 예시는 아래 그림(15-7)과 같다. + +![최대 용량 문제 예시 데이터](max_capacity_problem.assets/max_capacity_example.png){ class="animation-figure" } + +

그림 15-7   최대 용량 문제 예시 데이터

+ +임의의 두 칸막이로 용기를 만들므로 **이 문제의 상태는 두 칸막이의 인덱스 $[i, j]$다**. + +문제에 따르면 용량은 높이와 너비의 곱이고, 높이는 낮은 칸막이로 결정되며 너비는 두 인덱스의 차다. 용량을 $cap[i, j]$라고 하면 다음 공식을 얻는다. + +$$ +cap[i, j] = \min(ht[i], ht[j]) \times (j - i) +$$ + +배열 길이가 $n$이면 두 칸막이의 조합 수(전체 상태 수)는 $C_n^2 = \frac{n(n - 1)}{2}$이다. 가장 직접적으로는 **모든 상태를 열거하여** 최대 용량을 구할 수 있으며, 시간 복잡도는 $O(n^2)$이다. + +### 1.   탐욕 전략 결정 + +더 효율적인 풀이도 있다. 아래 그림(15-8)처럼 인덱스 $i < j$이고 높이 $ht[i] < ht[j]$인 상태 $[i, j]$를 고르자. 즉, $i$는 낮은 칸막이, $j$는 높은 칸막이다. + +![초기 상태](max_capacity_problem.assets/max_capacity_initial_state.png){ class="animation-figure" } + +

그림 15-8   초기 상태

+ +아래 그림(15-9)과 같이 **높은 칸막이 $j$를 낮은 칸막이 $i$ 쪽으로 옮기면 용량은 반드시 작아진다**. + +$j$를 옮기면 너비 $j-i$는 확실히 줄어든다. 높이는 낮은 칸막이로 결정되므로 그대로이거나($i$가 계속 더 낮음), 더 낮아질 수만 있다(이동한 $j$가 더 낮아짐). + +![높은 칸막이를 안쪽으로 옮긴 상태](max_capacity_problem.assets/max_capacity_moving_long_board.png){ class="animation-figure" } + +

그림 15-9   높은 칸막이를 안쪽으로 옮긴 상태

+ +반대로 생각하면 **낮은 칸막이 $i$를 안쪽으로 옮겨야만 용량이 커질 가능성이 있다**. 너비는 반드시 줄지만 **높이는 커질 수도 있기 때문이다**(이동한 $i$가 더 높을 수 있다). 아래 그림(15-10)에서는 낮은 칸막이를 옮긴 뒤 넓이가 커진다. + +![낮은 칸막이를 안쪽으로 옮긴 상태](max_capacity_problem.assets/max_capacity_moving_short_board.png){ class="animation-figure" } + +

그림 15-10   낮은 칸막이를 안쪽으로 옮긴 상태

+ +따라서 두 포인터를 용기의 양 끝에 놓고, 매번 낮은 칸막이 쪽 포인터를 안쪽으로 옮겨 두 포인터가 만날 때까지 진행하는 탐욕 전략을 도출할 수 있다. + +아래 그림(15-11)은 탐욕 전략의 실행 과정이다. + +1. 처음에는 포인터 $i$와 $j$를 배열의 양 끝에 둔다. +2. 현재 상태의 용량 $cap[i, j]$를 계산하고 최대 용량을 갱신한다. +3. 칸막이 $i$와 $j$의 높이를 비교하여 낮은 쪽을 안쪽으로 한 칸 옮긴다. +4. $i$와 $j$가 만날 때까지 `2.` 단계와 `3.` 단계를 반복한다. + +=== "<1>" + ![최대 용량 문제의 탐욕 과정](max_capacity_problem.assets/max_capacity_greedy_step1.png){ class="animation-figure" } + +=== "<2>" + ![최대 용량 탐욕 과정 2단계](max_capacity_problem.assets/max_capacity_greedy_step2.png){ class="animation-figure" } + +=== "<3>" + ![최대 용량 탐욕 과정 3단계](max_capacity_problem.assets/max_capacity_greedy_step3.png){ class="animation-figure" } + +=== "<4>" + ![최대 용량 탐욕 과정 4단계](max_capacity_problem.assets/max_capacity_greedy_step4.png){ class="animation-figure" } + +=== "<5>" + ![최대 용량 탐욕 과정 5단계](max_capacity_problem.assets/max_capacity_greedy_step5.png){ class="animation-figure" } + +=== "<6>" + ![최대 용량 탐욕 과정 6단계](max_capacity_problem.assets/max_capacity_greedy_step6.png){ class="animation-figure" } + +=== "<7>" + ![최대 용량 탐욕 과정 7단계](max_capacity_problem.assets/max_capacity_greedy_step7.png){ class="animation-figure" } + +=== "<8>" + ![최대 용량 탐욕 과정 8단계](max_capacity_problem.assets/max_capacity_greedy_step8.png){ class="animation-figure" } + +=== "<9>" + ![최대 용량 탐욕 과정 9단계](max_capacity_problem.assets/max_capacity_greedy_step9.png){ class="animation-figure" } + +

그림 15-11   최대 용량 문제의 탐욕 과정

+ +### 2.   코드 구현 + +반복문은 최대 $n$ 회 실행되므로 **시간 복잡도는 $O(n)$이다**. + +변수 $i$, $j$, $res$는 상수 크기의 추가 공간을 사용하므로 **공간 복잡도는 $O(1)$이다**. + +=== "Python" + + ```python title="max_capacity.py" + def max_capacity(ht: list[int]) -> int: + """최대 용량: 탐욕""" + # i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + i, j = 0, len(ht) - 1 + # 최대 용량의 초깃값은 0이다 + res = 0 + # 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while i < j: + # 최대 용량을 갱신한다 + cap = min(ht[i], ht[j]) * (j - i) + res = max(res, cap) + # 낮은 칸막이를 안쪽으로 이동한다 + if ht[i] < ht[j]: + i += 1 + else: + j -= 1 + return res + ``` + +=== "C++" + + ```cpp title="max_capacity.cpp" + /* 최대 용량: 탐욕 */ + int maxCapacity(vector &ht) { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + int i = 0, j = ht.size() - 1; + // 최대 용량의 초깃값은 0이다 + int res = 0; + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while (i < j) { + // 최대 용량을 갱신한다 + int cap = min(ht[i], ht[j]) * (j - i); + res = max(res, cap); + // 낮은 칸막이를 안쪽으로 이동한다 + if (ht[i] < ht[j]) { + i++; + } else { + j--; + } + } + return res; + } + ``` + +=== "Java" + + ```java title="max_capacity.java" + /* 최대 용량: 탐욕 */ + int maxCapacity(int[] ht) { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + int i = 0, j = ht.length - 1; + // 최대 용량의 초깃값은 0이다 + int res = 0; + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while (i < j) { + // 최대 용량을 갱신한다 + int cap = Math.min(ht[i], ht[j]) * (j - i); + res = Math.max(res, cap); + // 낮은 칸막이를 안쪽으로 이동한다 + if (ht[i] < ht[j]) { + i++; + } else { + j--; + } + } + return res; + } + ``` + +=== "C#" + + ```csharp title="max_capacity.cs" + /* 최대 용량: 탐욕 */ + int MaxCapacity(int[] ht) { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + int i = 0, j = ht.Length - 1; + // 최대 용량의 초깃값은 0이다 + int res = 0; + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while (i < j) { + // 최대 용량을 갱신한다 + int cap = Math.Min(ht[i], ht[j]) * (j - i); + res = Math.Max(res, cap); + // 낮은 칸막이를 안쪽으로 이동한다 + if (ht[i] < ht[j]) { + i++; + } else { + j--; + } + } + return res; + } + ``` + +=== "Go" + + ```go title="max_capacity.go" + /* 최대 용량: 탐욕 */ + func maxCapacity(ht []int) int { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + i, j := 0, len(ht)-1 + // 최대 용량의 초깃값은 0이다 + res := 0 + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + for i < j { + // 최대 용량을 갱신한다 + capacity := int(math.Min(float64(ht[i]), float64(ht[j]))) * (j - i) + res = int(math.Max(float64(res), float64(capacity))) + // 낮은 칸막이를 안쪽으로 이동한다 + if ht[i] < ht[j] { + i++ + } else { + j-- + } + } + return res + } + ``` + +=== "Swift" + + ```swift title="max_capacity.swift" + /* 최대 용량: 탐욕 */ + func maxCapacity(ht: [Int]) -> Int { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + var i = ht.startIndex, j = ht.endIndex - 1 + // 최대 용량의 초깃값은 0이다 + var res = 0 + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while i < j { + // 최대 용량을 갱신한다 + let cap = min(ht[i], ht[j]) * (j - i) + res = max(res, cap) + // 낮은 칸막이를 안쪽으로 이동한다 + if ht[i] < ht[j] { + i += 1 + } else { + j -= 1 + } + } + return res + } + ``` + +=== "JS" + + ```javascript title="max_capacity.js" + /* 최대 용량: 탐욕 */ + function maxCapacity(ht) { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + let i = 0, + j = ht.length - 1; + // 최대 용량의 초깃값은 0이다 + let res = 0; + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while (i < j) { + // 최대 용량을 갱신한다 + const cap = Math.min(ht[i], ht[j]) * (j - i); + res = Math.max(res, cap); + // 낮은 칸막이를 안쪽으로 이동한다 + if (ht[i] < ht[j]) { + i += 1; + } else { + j -= 1; + } + } + return res; + } + ``` + +=== "TS" + + ```typescript title="max_capacity.ts" + /* 최대 용량: 탐욕 */ + function maxCapacity(ht: number[]): number { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + let i = 0, + j = ht.length - 1; + // 최대 용량의 초깃값은 0이다 + let res = 0; + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while (i < j) { + // 최대 용량을 갱신한다 + const cap: number = Math.min(ht[i], ht[j]) * (j - i); + res = Math.max(res, cap); + // 낮은 칸막이를 안쪽으로 이동한다 + if (ht[i] < ht[j]) { + i += 1; + } else { + j -= 1; + } + } + return res; + } + ``` + +=== "Dart" + + ```dart title="max_capacity.dart" + /* 최대 용량: 탐욕 */ + int maxCapacity(List ht) { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + int i = 0, j = ht.length - 1; + // 최대 용량의 초깃값은 0이다 + int res = 0; + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while (i < j) { + // 최대 용량을 갱신한다 + int cap = min(ht[i], ht[j]) * (j - i); + res = max(res, cap); + // 낮은 칸막이를 안쪽으로 이동한다 + if (ht[i] < ht[j]) { + i++; + } else { + j--; + } + } + return res; + } + ``` + +=== "Rust" + + ```rust title="max_capacity.rs" + /* 최대 용량: 탐욕 */ + fn max_capacity(ht: &[i32]) -> i32 { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + let mut i = 0; + let mut j = ht.len() - 1; + // 최대 용량의 초깃값은 0이다 + let mut res = 0; + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while i < j { + // 최대 용량을 갱신한다 + let cap = std::cmp::min(ht[i], ht[j]) * (j - i) as i32; + res = std::cmp::max(res, cap); + // 낮은 칸막이를 안쪽으로 이동한다 + if ht[i] < ht[j] { + i += 1; + } else { + j -= 1; + } + } + res + } + ``` + +=== "C" + + ```c title="max_capacity.c" + /* 최대 용량: 탐욕 */ + int maxCapacity(int ht[], int htLength) { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + int i = 0; + int j = htLength - 1; + // 최대 용량의 초깃값은 0이다 + int res = 0; + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while (i < j) { + // 최대 용량을 갱신한다 + int capacity = myMin(ht[i], ht[j]) * (j - i); + res = myMax(res, capacity); + // 낮은 칸막이를 안쪽으로 이동한다 + if (ht[i] < ht[j]) { + i++; + } else { + j--; + } + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="max_capacity.kt" + /* 최대 용량: 탐욕 */ + fun maxCapacity(ht: IntArray): Int { + // i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + var i = 0 + var j = ht.size - 1 + // 최대 용량의 초깃값은 0이다 + var res = 0 + // 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while (i < j) { + // 최대 용량을 갱신한다 + val cap = min(ht[i], ht[j]) * (j - i) + res = max(res, cap) + // 낮은 칸막이를 안쪽으로 이동한다 + if (ht[i] < ht[j]) { + i++ + } else { + j-- + } + } + return res + } + ``` + +=== "Ruby" + + ```ruby title="max_capacity.rb" + ### 최대 용량: 탐욕 ### + def max_capacity(ht) + # i와 j가 각각 배열의 양 끝을 가리키도록 초기화한다 + i, j = 0, ht.length - 1 + # 최대 용량의 초깃값은 0이다 + res = 0 + + # 두 칸막이가 만날 때까지 탐욕적 선택을 반복한다 + while i < j + # 최대 용량을 갱신한다 + cap = [ht[i], ht[j]].min * (j - i) + res = [res, cap].max + # 낮은 칸막이를 안쪽으로 이동한다 + if ht[i] < ht[j] + i += 1 + else + j -= 1 + end + end + + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 3.   정확성 증명 + +탐욕 알고리즘이 모든 경우 열거보다 빠른 이유는 매번 일부 상태를 '건너뛰기' 때문이다. + +예를 들어 상태 $cap[i, j]$에서 $i$가 낮은 칸막이, $j$가 높은 칸막이라고 하자. 낮은 $i$를 탐욕적으로 한 칸 안쪽으로 옮기면 아래 그림(15-12)의 상태들을 건너뛴다. **이는 이후에 그 상태들의 용량을 확인할 수 없다는 뜻이다**. + +$$ +cap[i, i+1], cap[i, i+2], \dots, cap[i, j-2], cap[i, j-1] +$$ + +![낮은 칸막이를 옮겨 건너뛴 상태들](max_capacity_problem.assets/max_capacity_skipped_states.png){ class="animation-figure" } + +

그림 15-12   낮은 칸막이를 옮겨 건너뛴 상태들

+ +**건너뛴 상태들은 사실 높은 칸막이 $j$를 안쪽으로 옮긴 모든 상태다**. 앞에서 높은 칸막이를 안쪽으로 옮기면 용량이 반드시 줄어듦을 보였다. 따라서 건너뛴 상태는 최적해가 될 수 없으며, **이를 건너뛰어도 최적해를 놓치지 않는다**. + +이 분석은 낮은 칸막이를 옮기는 연산이 '안전'하며 탐욕 전략이 유효함을 보여 준다. diff --git a/ko/docs/chapter_greedy/max_product_cutting_problem.md b/ko/docs/chapter_greedy/max_product_cutting_problem.md new file mode 100644 index 000000000..692d57a5c --- /dev/null +++ b/ko/docs/chapter_greedy/max_product_cutting_problem.md @@ -0,0 +1,411 @@ +--- +comments: true +--- + +# 15.4   정수 분할의 최대 곱 문제 + +!!! question "문제" + + 양의 정수 $n$을 두 개 이상의 양의 정수의 합으로 나누었을 때, 나눈 정수들의 곱이 최대 얼마가 되는지 구하라. 아래 그림(15-13)을 참고하자. + +![정수 분할의 최대 곱 문제 정의](max_product_cutting_problem.assets/max_product_cutting_definition.png){ class="animation-figure" } + +

그림 15-13   정수 분할의 최대 곱 문제 정의

+ +$n$을 $m$ 개의 정수 인수로 나누고 $i$ 번째 인수를 $n_i$라고 하면 다음과 같다. + +$$ +n = \sum_{i=1}^{m}n_i +$$ + +목표는 모든 정수 인수의 곱을 최대화하는 것이다. + +$$ +\max(\prod_{i=1}^{m}n_i) +$$ + +생각해야 할 것은 나누는 개수 $m$과 각 $n_i$의 값이다. + +### 1.   탐욕 전략 결정 + +경험상 두 정수의 곱은 합보다 큰 경우가 많다. $n$에서 인수 $2$를 나누어 내면 곱은 $2(n-2)$다. 이를 $n$과 비교해 보자. + +$$ +\begin{aligned} +2(n-2) & \geq n \newline +2n - n - 4 & \geq 0 \newline +n & \geq 4 +\end{aligned} +$$ + +아래 그림(15-14)과 같이 $n \geq 4$이면 $2$를 나누어 낸 뒤 곱이 커진다. **이는 $4$ 이상의 정수는 모두 나누어야 한다는 뜻이다**. + +**탐욕 전략 1**: 분할에 $\geq 4$인 인수가 있으면 계속 나누어야 한다. 최종 분할에는 $1$, $2$, $3$만 있어야 한다. + +![분할로 커지는 곱](max_product_cutting_problem.assets/max_product_cutting_greedy_infer1.png){ class="animation-figure" } + +

그림 15-14   분할로 커지는 곱

+ +이제 어느 인수가 가장 좋은지 생각해 보자. $1$, $2$, $3$ 중 $1$이 가장 나쁘다는 것은 분명하다. $1 \times (n-1) < n$이 항상 성립하므로 $1$을 나누어 내면 오히려 곱이 줄어들기 때문이다. + +아래 그림(15-15)에서 $n = 6$일 때 $3 \times 3 > 2 \times 2 \times 2$다. **따라서 $2$보다 $3$으로 나누는 편이 더 좋다**. + +**탐욕 전략 2**: 분할에는 $2$가 많아도 두 개만 있어야 한다. $2$ 세 개는 언제나 $3$ 두 개로 바꾸어 더 큰 곱을 얻을 수 있기 때문이다. + +![최적인 분할 인수](max_product_cutting_problem.assets/max_product_cutting_greedy_infer2.png){ class="animation-figure" } + +

그림 15-15   최적인 분할 인수

+ +위 내용을 종합하면 다음 탐욕 전략을 얻는다. + +1. 정수 $n$에서 인수 $3$을 계속 나누어 내고, 나머지가 $0$, $1$, $2$가 되면 멈춘다. +2. 나머지가 $0$이면 $n$이 $3$의 배수이므로 추가로 처리하지 않는다. +3. 나머지가 $2$이면 더 나누지 않고 그대로 둔다. +4. 나머지가 $1$이면 $2 \times 2 > 1 \times 3$이므로 마지막 $3$과 나머지 $1$을 $2$ 두 개로 바꾼다. + +### 2.   코드 구현 + +아래 그림(15-16)과 같이 반복문으로 정수를 나눌 필요 없이 내림 정수 나눗셈으로 $3$의 개수 $a$를, 나머지 연산으로 나머지 $b$를 구할 수 있다. 이때 다음이 성립한다. + +$$ +n = 3 a + b +$$ + +경계 경우인 $n \leq 3$에서는 반드시 $1$을 하나 나누어 내야 하므로 곱은 $1 \times (n - 1)$이다. + +=== "Python" + + ```python title="max_product_cutting.py" + def max_product_cutting(n: int) -> int: + """분할 후의 최대 곱: 탐욕""" + # n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if n <= 3: + return 1 * (n - 1) + # 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + a, b = n // 3, n % 3 + if b == 1: + # 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return int(math.pow(3, a - 1)) * 2 * 2 + if b == 2: + # 나머지가 2이면 별도로 처리하지 않는다 + return int(math.pow(3, a)) * 2 + # 나머지가 0이면 별도로 처리하지 않는다 + return int(math.pow(3, a)) + ``` + +=== "C++" + + ```cpp title="max_product_cutting.cpp" + /* 분할 후의 최대 곱: 탐욕 */ + int maxProductCutting(int n) { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if (n <= 3) { + return 1 * (n - 1); + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + int a = n / 3; + int b = n % 3; + if (b == 1) { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return (int)pow(3, a - 1) * 2 * 2; + } + if (b == 2) { + // 나머지가 2이면 별도로 처리하지 않는다 + return (int)pow(3, a) * 2; + } + // 나머지가 0이면 별도로 처리하지 않는다 + return (int)pow(3, a); + } + ``` + +=== "Java" + + ```java title="max_product_cutting.java" + /* 분할 후의 최대 곱: 탐욕 */ + int maxProductCutting(int n) { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if (n <= 3) { + return 1 * (n - 1); + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + int a = n / 3; + int b = n % 3; + if (b == 1) { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return (int) Math.pow(3, a - 1) * 2 * 2; + } + if (b == 2) { + // 나머지가 2이면 별도로 처리하지 않는다 + return (int) Math.pow(3, a) * 2; + } + // 나머지가 0이면 별도로 처리하지 않는다 + return (int) Math.pow(3, a); + } + ``` + +=== "C#" + + ```csharp title="max_product_cutting.cs" + /* 분할 후의 최대 곱: 탐욕 */ + int MaxProductCutting(int n) { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if (n <= 3) { + return 1 * (n - 1); + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + int a = n / 3; + int b = n % 3; + if (b == 1) { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return (int)Math.Pow(3, a - 1) * 2 * 2; + } + if (b == 2) { + // 나머지가 2이면 별도로 처리하지 않는다 + return (int)Math.Pow(3, a) * 2; + } + // 나머지가 0이면 별도로 처리하지 않는다 + return (int)Math.Pow(3, a); + } + ``` + +=== "Go" + + ```go title="max_product_cutting.go" + /* 분할 후의 최대 곱: 탐욕 */ + func maxProductCutting(n int) int { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if n <= 3 { + return 1 * (n - 1) + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + a := n / 3 + b := n % 3 + if b == 1 { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return int(math.Pow(3, float64(a-1))) * 2 * 2 + } + if b == 2 { + // 나머지가 2이면 별도로 처리하지 않는다 + return int(math.Pow(3, float64(a))) * 2 + } + // 나머지가 0이면 별도로 처리하지 않는다 + return int(math.Pow(3, float64(a))) + } + ``` + +=== "Swift" + + ```swift title="max_product_cutting.swift" + /* 분할 후의 최대 곱: 탐욕 */ + func maxProductCutting(n: Int) -> Int { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if n <= 3 { + return 1 * (n - 1) + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + let a = n / 3 + let b = n % 3 + if b == 1 { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return pow(3, a - 1) * 2 * 2 + } + if b == 2 { + // 나머지가 2이면 별도로 처리하지 않는다 + return pow(3, a) * 2 + } + // 나머지가 0이면 별도로 처리하지 않는다 + return pow(3, a) + } + ``` + +=== "JS" + + ```javascript title="max_product_cutting.js" + /* 분할 후의 최대 곱: 탐욕 */ + function maxProductCutting(n) { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if (n <= 3) { + return 1 * (n - 1); + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + let a = Math.floor(n / 3); + let b = n % 3; + if (b === 1) { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return Math.pow(3, a - 1) * 2 * 2; + } + if (b === 2) { + // 나머지가 2이면 별도로 처리하지 않는다 + return Math.pow(3, a) * 2; + } + // 나머지가 0이면 별도로 처리하지 않는다 + return Math.pow(3, a); + } + ``` + +=== "TS" + + ```typescript title="max_product_cutting.ts" + /* 분할 후의 최대 곱: 탐욕 */ + function maxProductCutting(n: number): number { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if (n <= 3) { + return 1 * (n - 1); + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + let a: number = Math.floor(n / 3); + let b: number = n % 3; + if (b === 1) { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return Math.pow(3, a - 1) * 2 * 2; + } + if (b === 2) { + // 나머지가 2이면 별도로 처리하지 않는다 + return Math.pow(3, a) * 2; + } + // 나머지가 0이면 별도로 처리하지 않는다 + return Math.pow(3, a); + } + ``` + +=== "Dart" + + ```dart title="max_product_cutting.dart" + /* 분할 후의 최대 곱: 탐욕 */ + int maxProductCutting(int n) { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if (n <= 3) { + return 1 * (n - 1); + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + int a = n ~/ 3; + int b = n % 3; + if (b == 1) { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return (pow(3, a - 1) * 2 * 2).toInt(); + } + if (b == 2) { + // 나머지가 2이면 별도로 처리하지 않는다 + return (pow(3, a) * 2).toInt(); + } + // 나머지가 0이면 별도로 처리하지 않는다 + return pow(3, a).toInt(); + } + ``` + +=== "Rust" + + ```rust title="max_product_cutting.rs" + /* 분할 후의 최대 곱: 탐욕 */ + fn max_product_cutting(n: i32) -> i32 { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if n <= 3 { + return 1 * (n - 1); + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + let a = n / 3; + let b = n % 3; + if b == 1 { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + 3_i32.pow(a as u32 - 1) * 2 * 2 + } else if b == 2 { + // 나머지가 2이면 별도로 처리하지 않는다 + 3_i32.pow(a as u32) * 2 + } else { + // 나머지가 0이면 별도로 처리하지 않는다 + 3_i32.pow(a as u32) + } + } + ``` + +=== "C" + + ```c title="max_product_cutting.c" + /* 분할 후의 최대 곱: 탐욕 */ + int maxProductCutting(int n) { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if (n <= 3) { + return 1 * (n - 1); + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + int a = n / 3; + int b = n % 3; + if (b == 1) { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return pow(3, a - 1) * 2 * 2; + } + if (b == 2) { + // 나머지가 2이면 별도로 처리하지 않는다 + return pow(3, a) * 2; + } + // 나머지가 0이면 별도로 처리하지 않는다 + return pow(3, a); + } + ``` + +=== "Kotlin" + + ```kotlin title="max_product_cutting.kt" + /* 분할 후의 최대 곱: 탐욕 */ + fun maxProductCutting(n: Int): Int { + // n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + if (n <= 3) { + return 1 * (n - 1) + } + // 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + val a = n / 3 + val b = n % 3 + if (b == 1) { + // 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return 3.0.pow((a - 1)).toInt() * 2 * 2 + } + if (b == 2) { + // 나머지가 2이면 별도로 처리하지 않는다 + return 3.0.pow(a).toInt() * 2 * 2 + } + // 나머지가 0이면 별도로 처리하지 않는다 + return 3.0.pow(a).toInt() + } + ``` + +=== "Ruby" + + ```ruby title="max_product_cutting.rb" + ### 분할 후의 최대 곱: 탐욕 ### + def max_product_cutting(n) + # n <= 3이면 반드시 1 하나가 나오도록 분할해야 한다 + return 1 * (n - 1) if n <= 3 + # 탐욕적으로 3을 떼어 낸다. a는 3의 개수이고 b는 나머지이다 + a, b = n / 3, n % 3 + # 나머지가 1이면 1 * 3 한 쌍을 2 * 2로 바꾼다 + return (3.pow(a - 1) * 2 * 2).to_i if b == 1 + # 나머지가 2이면 별도로 처리하지 않는다 + return (3.pow(a) * 2).to_i if b == 2 + # 나머지가 0이면 별도로 처리하지 않는다 + 3.pow(a).to_i + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +![정수 분할 최대 곱의 계산 방법](max_product_cutting_problem.assets/max_product_cutting_greedy_calculation.png){ class="animation-figure" } + +

그림 15-16   정수 분할 최대 곱의 계산 방법

+ +**시간 복잡도는 프로그래밍 언어의 거듭제곱 구현 방식에 달려 있다**. Python에서 자주 쓰는 거듭제곱 계산 방법은 세 가지다. + +- 연산자 `**`와 함수 `pow()`의 시간 복잡도는 모두 $O(\log⁡ a)$이다. +- 함수 `math.pow()`는 내부적으로 C 라이브러리의 `pow()`를 호출하여 부동 소수점 거듭제곱을 계산하며, 시간 복잡도는 $O(1)$이다. + +변수 $a$와 $b$는 상수 크기의 추가 공간을 사용하므로 **공간 복잡도는 $O(1)$이다**. + +### 3.   정확성 증명 + +귀류법을 사용하며 $n \geq 4$인 경우만 분석한다. + +1. **모든 인수는 $\leq 3$이다**: 최적 분할에 $\geq 4$인 인수 $x$가 있다고 가정하자. 이를 $2(x-2)$로 더 나누면 곱이 커지거나 같아진다. 이는 가정과 모순이다. +2. **분할에 $1$이 없다**: 최적 분할에 인수 $1$이 있다고 가정하자. 이를 다른 인수에 합치면 더 큰 곱을 얻으므로 가정과 모순이다. +3. **분할에 $2$는 최대 두 개다**: 최적 분할에 $2$가 세 개 있다고 가정하자. $3$ 두 개로 바꾸면 곱이 더 커지므로 가정과 모순이다. diff --git a/ko/docs/chapter_greedy/summary.md b/ko/docs/chapter_greedy/summary.md new file mode 100644 index 000000000..4f7fb36cb --- /dev/null +++ b/ko/docs/chapter_greedy/summary.md @@ -0,0 +1,18 @@ +--- +comments: true +--- + +# 15.5   요약 + +### 1.   핵심 정리 + +- 탐욕 알고리즘은 주로 최적화 문제에 사용하며, 매 결정 단계에서 국소 최적 선택을 내려 전역 최적해를 얻으려는 원리다. +- 탐욕 알고리즘은 선택을 하나씩 반복하며 매번 문제를 더 작은 부분 문제로 바꾸고, 해결될 때까지 진행한다. +- 탐욕 알고리즘은 구현이 간단하고 풀이 효율이 높다. 시간 복잡도는 보통 동적 계획법보다 낮다. +- 동전 교환에서는 일부 동전 조합에 대해 최적해를 보장하지만, 다른 조합에서는 매우 나쁜 해를 찾을 수도 있다. +- 탐욕 알고리즘에 적합한 문제는 탐욕적 선택 속성과 최적 부분 구조를 가진다. 탐욕적 선택 속성은 전략의 유효성을 나타낸다. +- 복잡한 문제에서는 탐욕적 선택 속성의 증명이 쉽지 않다. 동전 교환처럼 반례로 부정하는 편이 상대적으로 쉬운 경우가 있다. +- 탐욕 문제의 풀이는 문제 분석, 탐욕 전략 결정, 정확성 증명의 세 단계로 나뉜다. 전략 결정이 핵심이며, 정확성 증명이 어려운 경우가 많다. +- 분할 가능한 배낭은 0-1 배낭에서 물건의 일부를 고를 수 있도록 바꾼 문제이므로 탐욕 알고리즘으로 풀 수 있다. 전략의 정확성은 귀류법으로 증명할 수 있다. +- 최대 용량 문제는 모든 경우를 열거하면 시간 복잡도가 $O(n^2)$이다. 매번 낮은 칸막이를 안쪽으로 옮기는 탐욕 전략으로 $O(n)$까지 최적화할 수 있다. +- 정수 분할의 최대 곱 문제에서는 $\geq 4$인 정수를 계속 나누어야 하고 최적인 인수는 $3$이라는 두 탐욕 전략을 차례로 도출했다. 코드에는 거듭제곱이 포함되므로 시간 복잡도는 구현 방식에 따라 보통 $O(1)$ 또는 $O(\log n)$이다. diff --git a/ko/docs/chapter_hashing/exercises.md b/ko/docs/chapter_hashing/exercises.md new file mode 100644 index 000000000..6d820dcd9 --- /dev/null +++ b/ko/docs/chapter_hashing/exercises.md @@ -0,0 +1,105 @@ +--- +comments: true +--- + +# 6.5   연습 문제 + +## 6.5.1   개념 확인 + +### 1.   해시 충돌이 일어난 뒤 탐색하는 방법 + +버킷이 5개인 해시 테이블의 해시 함수는 $h(x)=x \bmod 5$다. 충돌이 나면 해당 버킷의 리스트에 원소를 순서대로 넣는다. +`[1, 6, 11, 7]`을 차례대로 삽입한다. + + + +1. 0~4번 버킷의 내용을 적어 보자. +2. 6을 찾을 때 어느 버킷으로 들어가고, 어떤 원소를 차례대로 확인하는가? +3. 1번에서 적은 버킷의 내용을 보면 나중에 삽입한 원소가 먼저 삽입한 원소를 덮어썼는가? 이 충돌 처리 방식과 연결하여 이유를 설명하라. + +??? success "참고 답안" + + 1. $1\bmod5=6\bmod5=11\bmod5=1$이고 $7\bmod5=2$이므로 각 버킷의 내용은 다음과 같다. + + ```text + 0: [] + 1: [1, 6, 11] + 2: [7] + 3: [] + 4: [] + ``` + + 2. 6을 찾을 때는 먼저 1번 버킷으로 들어가 1, 6을 차례대로 비교한다. 두 번째 비교에서 목표를 찾는다. + + 3. 해시값이 같다는 것은 같은 버킷에 들어간다는 뜻일 뿐, 원소들이 같다는 뜻은 아니다. 체이닝은 충돌한 원소를 모두 버킷에 저장하고, + 탐색할 때 하나씩 비교한다. 따라서 1, 6, 11은 서로를 덮어쓰지 않는다. + +### 2.   해시 테이블의 용량을 늘리면 원소는 어디로 가는가 + +체이닝을 사용하는 해시 테이블에 원래 5개의 버킷이 있고, 해시 함수는 $h(x)=x\bmod5$다. +키 `[1, 6, 11]`은 모두 1번 버킷에 있다. + +이제 버킷 수를 7개로 늘리고 해시 함수도 $h(x)=x\bmod7$로 바꾼다. + + + +1. 1, 6, 11의 새 버킷 번호를 각각 계산하라. +2. 용량을 늘린 뒤 어느 버킷에 원소가 들어 있는가? +3. 기존 1번 버킷의 리스트를 새 1번 버킷에 그대로 복사해도 되는가? 1번과 2번의 결과를 바탕으로 이유를 설명하라. + +??? success "참고 답안" + + 1. 새 버킷 번호는 각각 다음과 같다. + + - $1\bmod7=1$ + - $6\bmod7=6$ + - $11\bmod7=4$ + + 2. 1번 버킷에 1, 4번 버킷에 11, 6번 버킷에 6이 저장된다. 세 키가 더 이상 하나의 버킷에 모여 있지 않다. + + 3. 그대로 복사해서는 안 된다. 버킷 번호는 키를 버킷 수로 나눈 나머지로 구한다. 버킷 수가 5에서 7로 바뀌면 같은 키의 버킷 번호도 달라질 수 있으므로, + 모든 키의 위치를 다시 계산해야 한다. 이전 1번 버킷을 그대로 복사하면 새 식으로 6과 11을 찾을 때 + 각각 6번 버킷과 4번 버킷으로 가게 되어 찾지 못한다. + +### 3.   6을 삭제한 뒤에도 11을 찾을 수 있는가 + +해시 테이블에 5개의 자리가 있고 인덱스는 `0~4`이며, 해시 함수는 $h(x)=x\bmod5$다. +충돌이 나면 해시 함수로 계산한 인덱스부터 오른쪽으로 이동하며 처음 만나는 빈자리를 찾는다. + +`[1, 6, 11]`을 차례대로 삽입한다. + + + +1. 세 수는 최종적으로 각각 어느 인덱스에 저장되는가? +2. 11을 찾을 때 어떤 인덱스를 차례대로 확인하는가? +3. 6을 삭제하면서 그 자리를 ‘한 번도 사용하지 않은 빈자리’로 바꾸고, 탐색은 빈자리를 만나면 멈춘다고 하자. + 다시 11을 찾으면 어떤 일이 일어나는가? 그 결과는 올바른가? 문제가 있다면 어떻게 피할 수 있는가? + +??? success "참고 답안" + + 1. 1은 인덱스 1에 저장된다. 6도 인덱스 1로 매핑되지만 충돌하므로 인덱스 2에 저장된다. + 11 역시 인덱스 1부터 시작해 이미 사용 중인 인덱스 1과 2를 지나 인덱스 3에 저장된다. + + 2. 11을 찾을 때는 인덱스 `1, 2, 3`을 차례대로 확인하고 인덱스 3에서 찾는다. + + 3. 인덱스 2를 ‘한 번도 사용하지 않음’을 뜻하는 빈자리로 바꾸면, 11을 찾다가 인덱스 1을 확인한 뒤 인덱스 2에서 멈춘다. + 따라서 11이 없다고 잘못 판단한다. 삭제할 때는 ‘삭제됨’ 표시를 남겨야 한다. + 탐색 중 이 표시를 만나면 다음 인덱스를 계속 확인하고, 인덱스 4를 지나면 0으로 돌아간다. 이후 삽입할 때는 이 자리를 다시 사용할 수 있다. + +## 6.5.2   프로그래밍 연습 + +### 1.   두 문자열의 문자 구성 비교하기 + +영어 소문자만 포함하는 두 문자열 `s`와 `t`가 주어진다. +`s`의 문자 위치는 자유롭게 바꿀 수 있지만 문자를 추가하거나 삭제하거나 다른 문자로 바꿀 수는 없다. + +순서를 바꿔 `t`를 만들 수 있는지 판단하고, 가능하면 `true`, 그렇지 않으면 `false`를 반환하라. +문자열의 문자를 정렬하지 말고 해시 테이블로 각 문자의 등장 횟수를 기록하라. + +??? tip "풀이 힌트" + + 1. 두 문자열의 길이가 다르면 문자 구성도 반드시 다르다. + 2. 해시 테이블에 각 문자의 개수를 기록한다. s를 순회할 때 해당 개수를 1씩 늘린다. + 3. t를 순회할 때 해당 개수를 1씩 줄인다. 마지막에 모든 개수가 0이어야 문자 구성이 같다. + +[LeetCode](https://leetcode.com/problems/valid-anagram/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_hashing/hash_algorithm.md b/ko/docs/chapter_hashing/hash_algorithm.md new file mode 100644 index 000000000..2821704ff --- /dev/null +++ b/ko/docs/chapter_hashing/hash_algorithm.md @@ -0,0 +1,1012 @@ +--- +comments: true +--- + +# 6.3   해시 알고리즘 + +앞의 두 절에서는 해시 테이블의 작동 원리와 해시 충돌 처리 방법을 알아보았다. 하지만 개방 주소법이든 체이닝이든 **충돌이 발생해도 해시 테이블이 정상적으로 작동하게 할 뿐, 충돌 발생 자체를 줄이지는 못한다**. + +충돌이 너무 자주 발생하면 성능이 급격히 나빠진다. 아래 그림(6-8)의 체이닝 해시 테이블에서 이상적인 경우는 키-값 쌍이 각 버킷에 고르게 분포하여 최고의 조회 효율을 얻는 것이다. 최악의 경우에는 모든 키-값 쌍이 같은 버킷에 들어가 시간 복잡도가 $O(n)$으로 나빠진다. + +![해시 충돌의 최선과 최악의 경우](hash_algorithm.assets/hash_collision_best_worst_condition.png){ class="animation-figure" } + +

그림 6-8   해시 충돌의 최선과 최악의 경우

+ +**키-값 쌍의 분포는 해시 함수가 결정한다**. 해시 함수는 먼저 해시값을 계산하고 배열 길이로 나눈 나머지를 구한다. + +```shell +index = hash(key) % capacity +``` + +위 식에서 해시 테이블 용량 `capacity`가 고정되면 **해시 알고리즘 `hash()`가 출력값을 결정하며**, 나아가 키-값 쌍의 분포를 결정한다. + +따라서 해시 충돌 확률을 낮추려면 해시 알고리즘 `hash()`의 설계에 집중해야 한다. + +## 6.3.1   해시 알고리즘의 목표 + +‘빠르고 안정적인’ 해시 테이블을 구현하려면 해시 알고리즘이 다음 특징을 갖추어야 한다. + +- **결정성**: 같은 입력에는 항상 같은 출력을 생성해야 한다. 그래야 해시 테이블을 신뢰할 수 있다. +- **높은 효율**: 해시값 계산이 충분히 빨라야 한다. 계산 비용이 작을수록 해시 테이블의 실용성이 높아진다. +- **균등 분포**: 키-값 쌍이 해시 테이블에 고르게 분포하도록 해야 한다. 분포가 고를수록 충돌 확률은 낮아진다. + +해시 알고리즘은 해시 테이블 외에도 여러 분야에서 널리 사용된다. + +- **비밀번호 저장**: 사용자의 비밀번호를 보호하기 위해 시스템은 보통 평문 대신 해시값을 저장한다. 사용자가 비밀번호를 입력하면 해시값을 계산해 저장된 값과 비교하고, 두 값이 같으면 올바른 비밀번호로 판단한다. +- **데이터 무결성 검사**: 송신자는 데이터의 해시값을 계산해 함께 보낼 수 있다. 수신자는 받은 데이터의 해시값을 다시 계산하여 전달받은 해시값과 비교한다. 둘이 같으면 데이터가 온전하다고 판단한다. + +암호학 관련 응용에서는 해시값으로 원래 비밀번호를 추론하는 등의 역공학을 막기 위해 더 높은 수준의 보안 특성이 필요하다. + +- **일방향성**: 해시값으로 입력 데이터에 관한 정보를 역으로 추론할 수 없어야 한다. +- **충돌 저항성**: 해시값이 같은 서로 다른 두 입력을 찾기가 극히 어려워야 한다. +- **눈사태 효과**: 입력의 작은 변화가 출력의 크고 예측할 수 없는 변화로 이어져야 한다. + +**‘균등 분포’와 ‘충돌 저항성’은 서로 독립된 개념이다**. 균등 분포를 만족한다고 충돌 저항성도 만족하는 것은 아니다. 예를 들어 `key`가 무작위로 입력되면 해시 함수 `key % 100`은 고르게 분포하는 출력을 만들 수 있다. 하지만 이 알고리즘은 너무 단순하여 마지막 두 자리가 같은 모든 `key`의 출력이 같다. 따라서 해시값에서 유효한 `key`를 쉽게 역추론하여 비밀번호를 깰 수 있다. + +## 6.3.2   해시 알고리즘의 설계 + +해시 알고리즘 설계는 여러 요소를 고려해야 하는 복잡한 문제다. 다만 요구 수준이 높지 않은 상황에서는 간단한 알고리즘을 설계할 수 있다. + +- **덧셈 해싱**: 입력의 각 문자에 해당하는 ASCII 코드를 모두 더한 값을 해시값으로 삼는다. +- **곱셈 해싱**: 곱셈의 비상관성을 이용하여 매번 상수를 곱하고, 각 문자의 ASCII 코드를 해시값에 누적한다. +- **XOR 해싱**: 입력 데이터의 각 원소를 XOR 연산으로 하나의 해시값에 누적한다. +- **회전 해싱**: 각 문자의 ASCII 코드를 해시값에 누적하되, 누적하기 전에 해시값에 회전 연산을 수행한다. + +=== "Python" + + ```python title="simple_hash.py" + def add_hash(key: str) -> int: + """덧셈 해시""" + hash = 0 + modulus = 1000000007 + for c in key: + hash += ord(c) + return hash % modulus + + def mul_hash(key: str) -> int: + """곱셈 해시""" + hash = 0 + modulus = 1000000007 + for c in key: + hash = 31 * hash + ord(c) + return hash % modulus + + def xor_hash(key: str) -> int: + """XOR 해시""" + hash = 0 + modulus = 1000000007 + for c in key: + hash ^= ord(c) + return hash % modulus + + def rot_hash(key: str) -> int: + """회전 해시""" + hash = 0 + modulus = 1000000007 + for c in key: + hash = (hash << 4) ^ (hash >> 28) ^ ord(c) + return hash % modulus + ``` + +=== "C++" + + ```cpp title="simple_hash.cpp" + /* 덧셈 해시 */ + int addHash(string key) { + long long hash = 0; + const int MODULUS = 1000000007; + for (unsigned char c : key) { + hash = (hash + (int)c) % MODULUS; + } + return (int)hash; + } + + /* 곱셈 해시 */ + int mulHash(string key) { + long long hash = 0; + const int MODULUS = 1000000007; + for (unsigned char c : key) { + hash = (31 * hash + (int)c) % MODULUS; + } + return (int)hash; + } + + /* XOR 해시 */ + int xorHash(string key) { + int hash = 0; + const int MODULUS = 1000000007; + for (unsigned char c : key) { + hash ^= (int)c; + } + return hash & MODULUS; + } + + /* 회전 해시 */ + int rotHash(string key) { + long long hash = 0; + const int MODULUS = 1000000007; + for (unsigned char c : key) { + hash = ((hash << 4) ^ (hash >> 28) ^ (int)c) % MODULUS; + } + return (int)hash; + } + ``` + +=== "Java" + + ```java title="simple_hash.java" + /* 덧셈 해시 */ + int addHash(String key) { + long hash = 0; + final int MODULUS = 1000000007; + for (char c : key.toCharArray()) { + hash = (hash + (int) c) % MODULUS; + } + return (int) hash; + } + + /* 곱셈 해시 */ + int mulHash(String key) { + long hash = 0; + final int MODULUS = 1000000007; + for (char c : key.toCharArray()) { + hash = (31 * hash + (int) c) % MODULUS; + } + return (int) hash; + } + + /* XOR 해시 */ + int xorHash(String key) { + int hash = 0; + final int MODULUS = 1000000007; + for (char c : key.toCharArray()) { + hash ^= (int) c; + } + return hash & MODULUS; + } + + /* 회전 해시 */ + int rotHash(String key) { + long hash = 0; + final int MODULUS = 1000000007; + for (char c : key.toCharArray()) { + hash = ((hash << 4) ^ (hash >> 28) ^ (int) c) % MODULUS; + } + return (int) hash; + } + ``` + +=== "C#" + + ```csharp title="simple_hash.cs" + /* 덧셈 해시 */ + int AddHash(string key) { + long hash = 0; + const int MODULUS = 1000000007; + foreach (char c in key) { + hash = (hash + c) % MODULUS; + } + return (int)hash; + } + + /* 곱셈 해시 */ + int MulHash(string key) { + long hash = 0; + const int MODULUS = 1000000007; + foreach (char c in key) { + hash = (31 * hash + c) % MODULUS; + } + return (int)hash; + } + + /* XOR 해시 */ + int XorHash(string key) { + int hash = 0; + const int MODULUS = 1000000007; + foreach (char c in key) { + hash ^= c; + } + return hash & MODULUS; + } + + /* 회전 해시 */ + int RotHash(string key) { + long hash = 0; + const int MODULUS = 1000000007; + foreach (char c in key) { + hash = ((hash << 4) ^ (hash >> 28) ^ c) % MODULUS; + } + return (int)hash; + } + ``` + +=== "Go" + + ```go title="simple_hash.go" + /* 덧셈 해시 */ + func addHash(key string) int { + var hash int64 + var modulus int64 + + modulus = 1000000007 + for _, b := range []byte(key) { + hash = (hash + int64(b)) % modulus + } + return int(hash) + } + + /* 곱셈 해시 */ + func mulHash(key string) int { + var hash int64 + var modulus int64 + + modulus = 1000000007 + for _, b := range []byte(key) { + hash = (31*hash + int64(b)) % modulus + } + return int(hash) + } + + /* XOR 해시 */ + func xorHash(key string) int { + hash := 0 + modulus := 1000000007 + for _, b := range []byte(key) { + fmt.Println(int(b)) + hash ^= int(b) + hash = (31*hash + int(b)) % modulus + } + return hash & modulus + } + + /* 회전 해시 */ + func rotHash(key string) int { + var hash int64 + var modulus int64 + + modulus = 1000000007 + for _, b := range []byte(key) { + hash = ((hash << 4) ^ (hash >> 28) ^ int64(b)) % modulus + } + return int(hash) + } + ``` + +=== "Swift" + + ```swift title="simple_hash.swift" + /* 덧셈 해시 */ + func addHash(key: String) -> Int { + var hash = 0 + let MODULUS = 1_000_000_007 + for c in key { + for scalar in c.unicodeScalars { + hash = (hash + Int(scalar.value)) % MODULUS + } + } + return hash + } + + /* 곱셈 해시 */ + func mulHash(key: String) -> Int { + var hash = 0 + let MODULUS = 1_000_000_007 + for c in key { + for scalar in c.unicodeScalars { + hash = (31 * hash + Int(scalar.value)) % MODULUS + } + } + return hash + } + + /* XOR 해시 */ + func xorHash(key: String) -> Int { + var hash = 0 + let MODULUS = 1_000_000_007 + for c in key { + for scalar in c.unicodeScalars { + hash ^= Int(scalar.value) + } + } + return hash & MODULUS + } + + /* 회전 해시 */ + func rotHash(key: String) -> Int { + var hash = 0 + let MODULUS = 1_000_000_007 + for c in key { + for scalar in c.unicodeScalars { + hash = ((hash << 4) ^ (hash >> 28) ^ Int(scalar.value)) % MODULUS + } + } + return hash + } + ``` + +=== "JS" + + ```javascript title="simple_hash.js" + /* 덧셈 해시 */ + function addHash(key) { + let hash = 0; + const MODULUS = 1000000007; + for (const c of key) { + hash = (hash + c.charCodeAt(0)) % MODULUS; + } + return hash; + } + + /* 곱셈 해시 */ + function mulHash(key) { + let hash = 0; + const MODULUS = 1000000007; + for (const c of key) { + hash = (31 * hash + c.charCodeAt(0)) % MODULUS; + } + return hash; + } + + /* XOR 해시 */ + function xorHash(key) { + let hash = 0; + const MODULUS = 1000000007; + for (const c of key) { + hash ^= c.charCodeAt(0); + } + return hash % MODULUS; + } + + /* 회전 해시 */ + function rotHash(key) { + let hash = 0; + const MODULUS = 1000000007; + for (const c of key) { + hash = ((hash << 4) ^ (hash >> 28) ^ c.charCodeAt(0)) % MODULUS; + } + return hash; + } + ``` + +=== "TS" + + ```typescript title="simple_hash.ts" + /* 덧셈 해시 */ + function addHash(key: string): number { + let hash = 0; + const MODULUS = 1000000007; + for (const c of key) { + hash = (hash + c.charCodeAt(0)) % MODULUS; + } + return hash; + } + + /* 곱셈 해시 */ + function mulHash(key: string): number { + let hash = 0; + const MODULUS = 1000000007; + for (const c of key) { + hash = (31 * hash + c.charCodeAt(0)) % MODULUS; + } + return hash; + } + + /* XOR 해시 */ + function xorHash(key: string): number { + let hash = 0; + const MODULUS = 1000000007; + for (const c of key) { + hash ^= c.charCodeAt(0); + } + return hash % MODULUS; + } + + /* 회전 해시 */ + function rotHash(key: string): number { + let hash = 0; + const MODULUS = 1000000007; + for (const c of key) { + hash = ((hash << 4) ^ (hash >> 28) ^ c.charCodeAt(0)) % MODULUS; + } + return hash; + } + ``` + +=== "Dart" + + ```dart title="simple_hash.dart" + /* 덧셈 해시 */ + int addHash(String key) { + int hash = 0; + final int MODULUS = 1000000007; + for (int i = 0; i < key.length; i++) { + hash = (hash + key.codeUnitAt(i)) % MODULUS; + } + return hash; + } + + /* 곱셈 해시 */ + int mulHash(String key) { + int hash = 0; + final int MODULUS = 1000000007; + for (int i = 0; i < key.length; i++) { + hash = (31 * hash + key.codeUnitAt(i)) % MODULUS; + } + return hash; + } + + /* XOR 해시 */ + int xorHash(String key) { + int hash = 0; + final int MODULUS = 1000000007; + for (int i = 0; i < key.length; i++) { + hash ^= key.codeUnitAt(i); + } + return hash & MODULUS; + } + + /* 회전 해시 */ + int rotHash(String key) { + int hash = 0; + final int MODULUS = 1000000007; + for (int i = 0; i < key.length; i++) { + hash = ((hash << 4) ^ (hash >> 28) ^ key.codeUnitAt(i)) % MODULUS; + } + return hash; + } + ``` + +=== "Rust" + + ```rust title="simple_hash.rs" + /* 덧셈 해시 */ + fn add_hash(key: &str) -> i32 { + let mut hash = 0_i64; + const MODULUS: i64 = 1000000007; + + for c in key.chars() { + hash = (hash + c as i64) % MODULUS; + } + + hash as i32 + } + + /* 곱셈 해시 */ + fn mul_hash(key: &str) -> i32 { + let mut hash = 0_i64; + const MODULUS: i64 = 1000000007; + + for c in key.chars() { + hash = (31 * hash + c as i64) % MODULUS; + } + + hash as i32 + } + + /* XOR 해시 */ + fn xor_hash(key: &str) -> i32 { + let mut hash = 0_i64; + const MODULUS: i64 = 1000000007; + + for c in key.chars() { + hash ^= c as i64; + } + + (hash & MODULUS) as i32 + } + + /* 회전 해시 */ + fn rot_hash(key: &str) -> i32 { + let mut hash = 0_i64; + const MODULUS: i64 = 1000000007; + + for c in key.chars() { + hash = ((hash << 4) ^ (hash >> 28) ^ c as i64) % MODULUS; + } + + hash as i32 + } + ``` + +=== "C" + + ```c title="simple_hash.c" + /* 덧셈 해시 */ + int addHash(char *key) { + long long hash = 0; + const int MODULUS = 1000000007; + for (int i = 0; i < strlen(key); i++) { + hash = (hash + (unsigned char)key[i]) % MODULUS; + } + return (int)hash; + } + + /* 곱셈 해시 */ + int mulHash(char *key) { + long long hash = 0; + const int MODULUS = 1000000007; + for (int i = 0; i < strlen(key); i++) { + hash = (31 * hash + (unsigned char)key[i]) % MODULUS; + } + return (int)hash; + } + + /* XOR 해시 */ + int xorHash(char *key) { + int hash = 0; + const int MODULUS = 1000000007; + + for (int i = 0; i < strlen(key); i++) { + hash ^= (unsigned char)key[i]; + } + return hash & MODULUS; + } + + /* 회전 해시 */ + int rotHash(char *key) { + long long hash = 0; + const int MODULUS = 1000000007; + for (int i = 0; i < strlen(key); i++) { + hash = ((hash << 4) ^ (hash >> 28) ^ (unsigned char)key[i]) % MODULUS; + } + + return (int)hash; + } + ``` + +=== "Kotlin" + + ```kotlin title="simple_hash.kt" + /* 덧셈 해시 */ + fun addHash(key: String): Int { + var hash = 0L + val MODULUS = 1000000007 + for (c in key.toCharArray()) { + hash = (hash + c.code) % MODULUS + } + return hash.toInt() + } + + /* 곱셈 해시 */ + fun mulHash(key: String): Int { + var hash = 0L + val MODULUS = 1000000007 + for (c in key.toCharArray()) { + hash = (31 * hash + c.code) % MODULUS + } + return hash.toInt() + } + + /* XOR 해시 */ + fun xorHash(key: String): Int { + var hash = 0 + val MODULUS = 1000000007 + for (c in key.toCharArray()) { + hash = hash xor c.code + } + return hash and MODULUS + } + + /* 회전 해시 */ + fun rotHash(key: String): Int { + var hash = 0L + val MODULUS = 1000000007 + for (c in key.toCharArray()) { + hash = ((hash shl 4) xor (hash shr 28) xor c.code.toLong()) % MODULUS + } + return hash.toInt() + } + ``` + +=== "Ruby" + + ```ruby title="simple_hash.rb" + ### 덧셈 해시 ### + def add_hash(key) + hash = 0 + modulus = 1_000_000_007 + + key.each_char { |c| hash += c.ord } + + hash % modulus + end + + ### 곱셈 해시 ### + def mul_hash(key) + hash = 0 + modulus = 1_000_000_007 + + key.each_char { |c| hash = 31 * hash + c.ord } + + hash % modulus + end + + ### XOR 해시 ### + def xor_hash(key) + hash = 0 + modulus = 1_000_000_007 + + key.each_char { |c| hash ^= c.ord } + + hash % modulus + end + + ### 회전 해시 ### + def rot_hash(key) + hash = 0 + modulus = 1_000_000_007 + + key.each_char { |c| hash = (hash << 4) ^ (hash >> 28) ^ c.ord } + + hash % modulus + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +각 알고리즘의 마지막 단계에서는 큰 소수 $1000000007$로 나눈 나머지를 구하여 해시값이 적절한 범위에 있도록 한다. 왜 소수로 나누어야 할까? 합성수로 나누면 어떤 단점이 있을까? 생각해 볼 만한 흥미로운 문제다. + +결론부터 말하면 **큰 소수를 모듈러 연산의 법으로 사용하면 해시값의 균등 분포를 최대한 보장할 수 있다**. 소수는 다른 수와 공약수를 갖지 않으므로 나머지 연산에서 생기는 주기적 패턴을 줄여 충돌을 피할 수 있기 때문이다. + +예를 들어 합성수 $9$를 법으로 선택했다고 하자. 이 수는 $3$으로 나누어떨어지므로, $3$으로 나누어떨어지는 모든 `key`는 $0$, $3$, $6$의 세 해시값에 매핑된다. + +$$ +\begin{aligned} +\text{modulus} & = 9 \newline +\text{key} & = \{ 0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, \dots \} \newline +\text{hash} & = \{ 0, 3, 6, 0, 3, 6, 0, 3, 6, 0, 3, 6,\dots \} +\end{aligned} +$$ + +입력 `key`가 마침 이런 등차수열 분포를 따르면 해시값에 군집이 생겨 충돌이 심해진다. 이제 `modulus`를 소수 $13$으로 바꾸면 `key`와 `modulus` 사이에 공약수가 없으므로 출력 해시값의 균등성이 뚜렷하게 좋아진다. + +$$ +\begin{aligned} +\text{modulus} & = 13 \newline +\text{key} & = \{ 0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, \dots \} \newline +\text{hash} & = \{ 0, 3, 6, 9, 12, 2, 5, 8, 11, 1, 4, 7, \dots \} +\end{aligned} +$$ + +`key`가 무작위로 균등하게 분포함을 보장할 수 있다면 소수와 합성수 중 어느 것을 법으로 써도 균등한 해시값을 얻을 수 있다. 반면 `key`의 분포에 주기성이 있으면 합성수로 나눌 때 군집이 생기기 쉽다. + +따라서 보통 소수를 법으로 선택하며, 가능한 한 큰 소수를 사용해 주기적 패턴을 줄이고 해시 알고리즘의 안정성을 높인다. + +## 6.3.3   대표적인 해시 알고리즘 + +앞에서 소개한 간단한 해시 알고리즘은 비교적 ‘취약하여’ 설계 목표에 크게 못 미친다. 예를 들어 덧셈과 XOR는 교환법칙을 만족하므로, 이 두 해싱 방식은 내용은 같고 순서만 다른 문자열을 구분하지 못한다. 이는 해시 충돌을 늘리고 보안 문제를 일으킬 수 있다. + +실제로는 MD5, SHA-1, SHA-2, SHA-3 같은 표준 해시 알고리즘을 주로 사용한다. 이들은 길이가 임의인 입력 데이터를 고정 길이의 해시값으로 매핑한다. + +지난 한 세기 가까이 해시 알고리즘은 계속 개선되고 최적화되어 왔다. 일부 연구자는 성능을 높이는 데 힘쓰고, 다른 연구자와 해커는 보안 문제를 찾는 데 집중한다. 아래 표(6-2)는 실제 응용에서 흔히 쓰이는 해시 알고리즘을 보여 준다. + +- MD5와 SHA-1은 여러 차례 공격에 성공한 사례가 있어 각종 보안 응용에서 사용이 중단되었다. +- SHA-2 계열의 SHA-256은 가장 안전한 해시 알고리즘 중 하나로, 아직 성공한 공격 사례가 없어 각종 보안 응용과 프로토콜에서 자주 사용된다. +- SHA-3는 SHA-2보다 구현 비용이 낮고 계산 효율이 높지만, 현재 보급 범위는 SHA-2 계열에 못 미친다. + +

표 6-2   대표적인 해시 알고리즘

+ +
+ +| | MD5 | SHA-1 | SHA-2 | SHA-3 | +| -------- | ------------------------------ | ---------------- | ---------------------------- | ------------------- | +| 발표 시기 | 1992 | 1995 | 2002 | 2008 | +| 출력 길이 | 128 bit | 160 bit | 256/512 bit | 224/256/384/512 bit | +| 해시 충돌 | 비교적 많음 | 비교적 많음 | 매우 적음 | 매우 적음 | +| 보안 수준 | 낮음, 공격 성공 사례가 있음 | 낮음, 공격 성공 사례가 있음 | 높음 | 높음 | +| 활용 | 사용 중단, 데이터 무결성 검사에는 여전히 사용 | 사용 중단 | 암호화폐 거래 검증, 전자 서명 등 | SHA-2를 대체할 수 있음 | + +
+ +## 6.3.4   자료 구조의 해시값 + +해시 테이블의 `key`는 정수, 소수, 문자열 등의 자료형일 수 있다. 프로그래밍 언어는 보통 이러한 자료형에 내장 해시 알고리즘을 제공하여 버킷 인덱스를 계산한다. 예를 들어 Python에서는 `hash()` 함수로 여러 자료형의 해시값을 구할 수 있다. + +- 정수와 불리언의 해시값은 그 값 자체다. +- 부동 소수점 수와 문자열의 해시값 계산은 더 복잡하다. 관심 있는 독자는 따로 알아보자. +- 튜플은 각 원소의 해시값을 계산한 뒤 이를 결합하여 하나의 해시값을 얻는다. +- 객체의 해시값은 메모리 주소를 기반으로 생성된다. 객체의 해시 메서드를 재정의하면 내용을 기반으로 해시값을 만들 수도 있다. + +!!! tip "참고" + + 프로그래밍 언어마다 내장 해시값 계산 함수의 정의와 방식이 다르다는 점에 주의하자. + +=== "Python" + + ```python title="built_in_hash.py" + num = 3 + hash_num = hash(num) + # 정수 3의 해시값은 3 + + bol = True + hash_bol = hash(bol) + # 불리언 True의 해시값은 1 + + dec = 3.14159 + hash_dec = hash(dec) + # 소수 3.14159의 해시값은 326484311674566659 + + str = "Hello 算法" + hash_str = hash(str) + # 문자열 “Hello 算法”의 해시값은 4617003410720528961 + + tup = (12836, "小哈") + hash_tup = hash(tup) + # 튜플 (12836, '小哈')의 해시값은 1029005403108185979 + + obj = ListNode(0) + hash_obj = hash(obj) + # 노드 객체 의 해시값은 274267521 + ``` + +=== "C++" + + ```cpp title="built_in_hash.cpp" + int num = 3; + size_t hashNum = hash()(num); + // 정수 3의 해시값은 3 + + bool bol = true; + size_t hashBol = hash()(bol); + // 불리언 1의 해시값은 1 + + double dec = 3.14159; + size_t hashDec = hash()(dec); + // 소수 3.14159의 해시값은 4614256650576692846 + + string str = "Hello 算法"; + size_t hashStr = hash()(str); + // 문자열 “Hello 算法”의 해시값은 15466937326284535026 + + // C++의 내장 std::hash()는 기본 자료형의 해시값 계산만 제공한다 + // 배열과 객체의 해시값 계산은 직접 구현해야 한다 + ``` + +=== "Java" + + ```java title="built_in_hash.java" + int num = 3; + int hashNum = Integer.hashCode(num); + // 정수 3의 해시값은 3 + + boolean bol = true; + int hashBol = Boolean.hashCode(bol); + // 불리언 true의 해시값은 1231 + + double dec = 3.14159; + int hashDec = Double.hashCode(dec); + // 소수 3.14159의 해시값은 -1340954729 + + String str = "Hello 算法"; + int hashStr = str.hashCode(); + // 문자열 “Hello 算法”의 해시값은 -727081396 + + Object[] arr = { 12836, "小哈" }; + int hashTup = Arrays.hashCode(arr); + // 배열 [12836, 小哈]의 해시값은 1151158 + + ListNode obj = new ListNode(0); + int hashObj = obj.hashCode(); + // 노드 객체 utils.ListNode@7dc5e7b4의 해시값은 2110121908 + ``` + +=== "C#" + + ```csharp title="built_in_hash.cs" + int num = 3; + int hashNum = num.GetHashCode(); + // 정수 3의 해시값은 3; + + bool bol = true; + int hashBol = bol.GetHashCode(); + // 불리언 true의 해시값은 1; + + double dec = 3.14159; + int hashDec = dec.GetHashCode(); + // 소수 3.14159의 해시값은 -1340954729; + + string str = "Hello 算法"; + int hashStr = str.GetHashCode(); + // 문자열 “Hello 算法”의 해시값은 -586107568; + + object[] arr = [12836, "小哈"]; + int hashTup = arr.GetHashCode(); + // 배열 [12836, 小哈]의 해시값은 42931033; + + ListNode obj = new(0); + int hashObj = obj.GetHashCode(); + // 노드 객체 0의 해시값은 39053774; + ``` + +=== "Go" + + ```go title="built_in_hash.go" + // Go는 내장 hash code 함수를 제공하지 않는다 + ``` + +=== "Swift" + + ```swift title="built_in_hash.swift" + let num = 3 + let hashNum = num.hashValue + // 정수 3의 해시값은 9047044699613009734 + + let bol = true + let hashBol = bol.hashValue + // 불리언 true의 해시값은 -4431640247352757451 + + let dec = 3.14159 + let hashDec = dec.hashValue + // 소수 3.14159의 해시값은 -2465384235396674631 + + let str = "Hello 算法" + let hashStr = str.hashValue + // 문자열 “Hello 算法”의 해시값은 -7850626797806988787 + + let arr = [AnyHashable(12836), AnyHashable("小哈")] + let hashTup = arr.hashValue + // 배열 [AnyHashable(12836), AnyHashable("小哈")]의 해시값은 -2308633508154532996 + + let obj = ListNode(x: 0) + let hashObj = obj.hashValue + // 노드 객체 utils.ListNode의 해시값은 -2434780518035996159 + ``` + +=== "JS" + + ```javascript title="built_in_hash.js" + // JavaScript는 내장 hash code 함수를 제공하지 않는다 + ``` + +=== "TS" + + ```typescript title="built_in_hash.ts" + // TypeScript는 내장 hash code 함수를 제공하지 않는다 + ``` + +=== "Dart" + + ```dart title="built_in_hash.dart" + int num = 3; + int hashNum = num.hashCode; + // 정수 3의 해시값은 34803 + + bool bol = true; + int hashBol = bol.hashCode; + // 불리언 true의 해시값은 1231 + + double dec = 3.14159; + int hashDec = dec.hashCode; + // 소수 3.14159의 해시값은 2570631074981783 + + String str = "Hello 算法"; + int hashStr = str.hashCode; + // 문자열 “Hello 算法”의 해시값은 468167534 + + List arr = [12836, "小哈"]; + int hashArr = arr.hashCode; + // 배열 [12836, 小哈]의 해시값은 976512528 + + ListNode obj = new ListNode(0); + int hashObj = obj.hashCode; + // 노드 객체 Instance of 'ListNode'의 해시값은 1033450432 + ``` + +=== "Rust" + + ```rust title="built_in_hash.rs" + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let num = 3; + let mut num_hasher = DefaultHasher::new(); + num.hash(&mut num_hasher); + let hash_num = num_hasher.finish(); + // 정수 3의 해시값은 568126464209439262 + + let bol = true; + let mut bol_hasher = DefaultHasher::new(); + bol.hash(&mut bol_hasher); + let hash_bol = bol_hasher.finish(); + // 불리언 true의 해시값은 4952851536318644461 + + let dec: f32 = 3.14159; + let mut dec_hasher = DefaultHasher::new(); + dec.to_bits().hash(&mut dec_hasher); + let hash_dec = dec_hasher.finish(); + // 소수 3.14159의 해시값은 2566941990314602357 + + let str = "Hello 算法"; + let mut str_hasher = DefaultHasher::new(); + str.hash(&mut str_hasher); + let hash_str = str_hasher.finish(); + // 문자열 “Hello 算法”의 해시값은 16092673739211250988 + + let arr = (&12836, &"小哈"); + let mut tup_hasher = DefaultHasher::new(); + arr.hash(&mut tup_hasher); + let hash_tup = tup_hasher.finish(); + // 튜플 (12836, "小哈")의 해시값은 1885128010422702749 + + let node = ListNode::new(42); + let mut hasher = DefaultHasher::new(); + node.borrow().val.hash(&mut hasher); + let hash = hasher.finish(); + // 노드 객체 RefCell { value: ListNode { val: 42, next: None } }의 해시값은 15387811073369036852 + ``` + +=== "C" + + ```c title="built_in_hash.c" + // C는 내장 hash code 함수를 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="built_in_hash.kt" + val num = 3 + val hashNum = num.hashCode() + // 정수 3의 해시값은 3 + + val bol = true + val hashBol = bol.hashCode() + // 불리언 true의 해시값은 1231 + + val dec = 3.14159 + val hashDec = dec.hashCode() + // 소수 3.14159의 해시값은 -1340954729 + + val str = "Hello 算法" + val hashStr = str.hashCode() + // 문자열 “Hello 算法”의 해시값은 -727081396 + + val arr = arrayOf(12836, "小哈") + val hashTup = arr.hashCode() + // 배열 [12836, 小哈]의 해시값은 189568618 + + val obj = ListNode(0) + val hashObj = obj.hashCode() + // 노드 객체 utils.ListNode@1d81eb93의 해시값은 495053715 + ``` + +=== "Ruby" + + ```ruby title="built_in_hash.rb" + num = 3 + hash_num = num.hash + # 정수 3의 해시값은 -4385856518450339636 + + bol = true + hash_bol = bol.hash + # 불리언 true의 해시값은 -1617938112149317027 + + dec = 3.14159 + hash_dec = dec.hash + # 소수 3.14159의 해시값은 -1479186995943067893 + + str = "Hello 算法" + hash_str = str.hash + # 문자열 “Hello 算法”의 해시값은 -4075943250025831763 + + tup = [12836, '小哈'] + hash_tup = tup.hash + # 튜플 (12836, '小哈')의 해시값은 1999544809202288822 + + obj = ListNode.new(0) + hash_obj = obj.hash + # 노드 객체 #의 해시값은 4302940560806366381 + ``` + +??? pythontutor "코드 시각화" + +
+ + +많은 프로그래밍 언어에서는 **불변 객체만 해시 테이블의 `key`로 사용할 수 있다**. 리스트(동적 배열)를 `key`로 사용하면 내용이 바뀔 때 해시값도 달라져, 기존의 `value`를 조회할 수 없게 된다. + +연결 리스트 노드 같은 사용자 정의 객체는 멤버 변수가 바뀔 수 있어도 해시 가능하다. **객체의 해시값은 보통 메모리 주소를 기반으로 생성되기 때문이다**. 내용이 바뀌어도 메모리 주소가 그대로면 해시값도 바뀌지 않는다. + +서로 다른 콘솔에서 실행하면 출력되는 해시값이 다르다는 점을 발견했을 것이다. **Python 인터프리터가 시작될 때마다 문자열 해시 함수에 무작위 솔트(salt) 값을 추가하기 때문이다**. 이 방식은 HashDoS 공격을 효과적으로 방지하여 해시 알고리즘의 보안성을 높인다. diff --git a/ko/docs/chapter_hashing/hash_collision.md b/ko/docs/chapter_hashing/hash_collision.md new file mode 100644 index 000000000..67cbf7e76 --- /dev/null +++ b/ko/docs/chapter_hashing/hash_collision.md @@ -0,0 +1,3318 @@ +--- +comments: true +--- + +# 6.2   해시 충돌 + +앞 절에서 설명했듯이 **해시 함수의 입력 공간은 보통 출력 공간보다 훨씬 크므로**, 이론적으로 해시 충돌은 피할 수 없다. 예를 들어 입력 공간이 모든 정수이고 출력 공간이 배열 용량만큼이라면, 여러 정수가 같은 버킷 인덱스에 매핑될 수밖에 없다. + +해시 충돌은 잘못된 조회 결과를 일으켜 해시 테이블의 사용 가능성을 크게 해친다. 이를 해결하려면 충돌이 생길 때마다 해시 테이블을 확장하여 충돌이 없어질 때까지 반복할 수 있다. 단순하고 효과적인 방법이지만, 확장할 때 많은 데이터를 옮기고 해시값을 계산해야 하므로 효율은 매우 낮다. 효율을 높이려면 다음 전략을 사용할 수 있다. + +1. 해시 테이블의 자료 구조를 개선하여 **해시 충돌이 발생해도 정상적으로 작동하게 한다**. +2. 필요한 경우, 즉 해시 충돌이 심할 때만 용량을 확장한다. + +해시 테이블의 구조를 개선하는 대표적인 방법은 ‘체이닝’과 ‘개방 주소법’이다. + +## 6.2.1   체이닝 + +기본 해시 테이블에서는 버킷 하나에 키-값 쌍 하나만 저장할 수 있다. 체이닝(separate chaining)은 단일 원소를 연결 리스트로 바꾸고, 키-값 쌍을 노드로 삼아 충돌한 키-값 쌍을 모두 같은 연결 리스트에 저장한다. 아래 그림(6-5)은 체이닝 해시 테이블의 예다. + +![체이닝 해시 테이블](hash_collision.assets/hash_table_chaining.png){ class="animation-figure" } + +

그림 6-5   체이닝 해시 테이블

+ +체이닝 해시 테이블에서는 연산 방식이 다음과 같이 바뀐다. + +- **원소 조회**: `key`를 입력하여 해시 함수로 버킷 인덱스를 구한 뒤 연결 리스트의 머리 노드에 접근한다. 이후 리스트를 순회하며 `key`를 비교하여 목표 키-값 쌍을 찾는다. +- **원소 추가**: 해시 함수로 연결 리스트의 머리 노드에 접근한 뒤, 노드(키-값 쌍)를 리스트에 추가한다. +- **원소 삭제**: 해시 함수의 결과로 연결 리스트의 맨 앞에 접근한 뒤, 순회하여 목표 노드를 찾아 삭제한다. + +체이닝에는 다음과 같은 한계가 있다. + +- **공간 사용량 증가**: 연결 리스트에는 노드 포인터가 있으므로 배열보다 메모리를 더 많이 사용한다. +- **조회 효율 감소**: 해당 원소를 찾으려면 연결 리스트를 선형으로 순회해야 한다. + +다음 코드는 간단한 체이닝 해시 테이블을 구현한다. 두 가지에 주의하자. + +- 코드를 단순화하기 위해 연결 리스트 대신 리스트(동적 배열)를 사용한다. 따라서 해시 테이블(배열)은 여러 버킷을 포함하며, 각 버킷은 리스트다. +- 아래 구현에는 용량 확장 메서드가 포함된다. 적재율이 $\frac{2}{3}$를 넘으면 해시 테이블을 기존 크기의 $2$배로 늘린다. + +=== "Python" + + ```python title="hash_map_chaining.py" + class HashMapChaining: + """체이닝 해시 테이블""" + + def __init__(self): + """생성자""" + self.size = 0 # 키-값 쌍 개수 + self.capacity = 4 # 해시 테이블 용량 + self.load_thres = 2.0 / 3.0 # 용량 확장을 수행하는 적재율 임계값 + self.extend_ratio = 2 # 용량 확장 배수 + self.buckets = [[] for _ in range(self.capacity)] # 버킷 배열 + + def hash_func(self, key: int) -> int: + """해시 함수""" + return key % self.capacity + + def load_factor(self) -> float: + """적재율""" + return self.size / self.capacity + + def get(self, key: int) -> str | None: + """조회 연산""" + index = self.hash_func(key) + bucket = self.buckets[index] + # 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for pair in bucket: + if pair.key == key: + return pair.val + # key를 찾지 못하면 None을 반환한다 + return None + + def put(self, key: int, val: str): + """추가 연산""" + # 적재율이 임계값을 초과하면 용량을 확장한다 + if self.load_factor() > self.load_thres: + self.extend() + index = self.hash_func(key) + bucket = self.buckets[index] + # 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for pair in bucket: + if pair.key == key: + pair.val = val + return + # 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + pair = Pair(key, val) + bucket.append(pair) + self.size += 1 + + def remove(self, key: int): + """삭제 연산""" + index = self.hash_func(key) + bucket = self.buckets[index] + # 버킷을 순회하며 키-값 쌍을 삭제한다 + for pair in bucket: + if pair.key == key: + bucket.remove(pair) + self.size -= 1 + break + + def extend(self): + """해시 테이블 용량 확장""" + # 원래 해시 테이블을 임시로 저장한다 + buckets = self.buckets + # 용량을 확장한 새 해시 테이블을 초기화한다 + self.capacity *= self.extend_ratio + self.buckets = [[] for _ in range(self.capacity)] + self.size = 0 + # 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for bucket in buckets: + for pair in bucket: + self.put(pair.key, pair.val) + + def print(self): + """해시 테이블 출력""" + for bucket in self.buckets: + res = [] + for pair in bucket: + res.append(str(pair.key) + " -> " + pair.val) + print(res) + ``` + +=== "C++" + + ```cpp title="hash_map_chaining.cpp" + /* 체이닝 해시 테이블 */ + class HashMapChaining { + private: + int size; // 키-값 쌍 개수 + int capacity; // 해시 테이블 용량 + double loadThres; // 용량 확장을 수행하는 적재율 임계값 + int extendRatio; // 용량 확장 배수 + vector> buckets; // 버킷 배열 + + public: + /* 생성자 */ + HashMapChaining() : size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2) { + buckets.resize(capacity); + } + + /* 소멸자 */ + ~HashMapChaining() { + for (auto &bucket : buckets) { + for (Pair *pair : bucket) { + // 메모리 해제 + delete pair; + } + } + } + + /* 해시 함수 */ + int hashFunc(int key) { + return key % capacity; + } + + /* 적재율 */ + double loadFactor() { + return (double)size / (double)capacity; + } + + /* 조회 연산 */ + string get(int key) { + int index = hashFunc(key); + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for (Pair *pair : buckets[index]) { + if (pair->key == key) { + return pair->val; + } + } + // key를 찾지 못하면 빈 문자열을 반환한다 + return ""; + } + + /* 추가 연산 */ + void put(int key, string val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (loadFactor() > loadThres) { + extend(); + } + int index = hashFunc(key); + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for (Pair *pair : buckets[index]) { + if (pair->key == key) { + pair->val = val; + return; + } + } + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + buckets[index].push_back(new Pair(key, val)); + size++; + } + + /* 삭제 연산 */ + void remove(int key) { + int index = hashFunc(key); + auto &bucket = buckets[index]; + // 버킷을 순회하며 키-값 쌍을 삭제한다 + for (int i = 0; i < bucket.size(); i++) { + if (bucket[i]->key == key) { + Pair *tmp = bucket[i]; + bucket.erase(bucket.begin() + i); // 해당 키-값 쌍을 삭제한다 + delete tmp; // 메모리 해제 + size--; + return; + } + } + } + + /* 해시 테이블 용량 확장 */ + void extend() { + // 원래 해시 테이블을 임시로 저장한다 + vector> bucketsTmp = buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio; + buckets.clear(); + buckets.resize(capacity); + size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (auto &bucket : bucketsTmp) { + for (Pair *pair : bucket) { + put(pair->key, pair->val); + // 메모리 해제 + delete pair; + } + } + } + + /* 해시 테이블 출력 */ + void print() { + for (auto &bucket : buckets) { + cout << "["; + for (Pair *pair : bucket) { + cout << pair->key << " -> " << pair->val << ", "; + } + cout << "]\n"; + } + } + }; + ``` + +=== "Java" + + ```java title="hash_map_chaining.java" + /* 체이닝 해시 테이블 */ + class HashMapChaining { + int size; // 키-값 쌍 개수 + int capacity; // 해시 테이블 용량 + double loadThres; // 용량 확장을 수행하는 적재율 임계값 + int extendRatio; // 용량 확장 배수 + List> buckets; // 버킷 배열 + + /* 생성자 */ + public HashMapChaining() { + size = 0; + capacity = 4; + loadThres = 2.0 / 3.0; + extendRatio = 2; + buckets = new ArrayList<>(capacity); + for (int i = 0; i < capacity; i++) { + buckets.add(new ArrayList<>()); + } + } + + /* 해시 함수 */ + int hashFunc(int key) { + return key % capacity; + } + + /* 적재율 */ + double loadFactor() { + return (double) size / capacity; + } + + /* 조회 연산 */ + String get(int key) { + int index = hashFunc(key); + List bucket = buckets.get(index); + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for (Pair pair : bucket) { + if (pair.key == key) { + return pair.val; + } + } + // key를 찾지 못하면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + void put(int key, String val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (loadFactor() > loadThres) { + extend(); + } + int index = hashFunc(key); + List bucket = buckets.get(index); + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for (Pair pair : bucket) { + if (pair.key == key) { + pair.val = val; + return; + } + } + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + Pair pair = new Pair(key, val); + bucket.add(pair); + size++; + } + + /* 삭제 연산 */ + void remove(int key) { + int index = hashFunc(key); + List bucket = buckets.get(index); + // 버킷을 순회하며 키-값 쌍을 삭제한다 + for (Pair pair : bucket) { + if (pair.key == key) { + bucket.remove(pair); + size--; + break; + } + } + } + + /* 해시 테이블 용량 확장 */ + void extend() { + // 원래 해시 테이블을 임시로 저장한다 + List> bucketsTmp = buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio; + buckets = new ArrayList<>(capacity); + for (int i = 0; i < capacity; i++) { + buckets.add(new ArrayList<>()); + } + size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (List bucket : bucketsTmp) { + for (Pair pair : bucket) { + put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + void print() { + for (List bucket : buckets) { + List res = new ArrayList<>(); + for (Pair pair : bucket) { + res.add(pair.key + " -> " + pair.val); + } + System.out.println(res); + } + } + } + ``` + +=== "C#" + + ```csharp title="hash_map_chaining.cs" + /* 체이닝 해시 테이블 */ + class HashMapChaining { + int size; // 키-값 쌍 개수 + int capacity; // 해시 테이블 용량 + double loadThres; // 용량 확장을 수행하는 적재율 임계값 + int extendRatio; // 용량 확장 배수 + List> buckets; // 버킷 배열 + + /* 생성자 */ + public HashMapChaining() { + size = 0; + capacity = 4; + loadThres = 2.0 / 3.0; + extendRatio = 2; + buckets = new List>(capacity); + for (int i = 0; i < capacity; i++) { + buckets.Add([]); + } + } + + /* 해시 함수 */ + int HashFunc(int key) { + return key % capacity; + } + + /* 적재율 */ + double LoadFactor() { + return (double)size / capacity; + } + + /* 조회 연산 */ + public string? Get(int key) { + int index = HashFunc(key); + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + foreach (Pair pair in buckets[index]) { + if (pair.key == key) { + return pair.val; + } + } + // key를 찾지 못하면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + public void Put(int key, string val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (LoadFactor() > loadThres) { + Extend(); + } + int index = HashFunc(key); + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + foreach (Pair pair in buckets[index]) { + if (pair.key == key) { + pair.val = val; + return; + } + } + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + buckets[index].Add(new Pair(key, val)); + size++; + } + + /* 삭제 연산 */ + public void Remove(int key) { + int index = HashFunc(key); + // 버킷을 순회하며 키-값 쌍을 삭제한다 + foreach (Pair pair in buckets[index].ToList()) { + if (pair.key == key) { + buckets[index].Remove(pair); + size--; + break; + } + } + } + + /* 해시 테이블 용량 확장 */ + void Extend() { + // 원래 해시 테이블을 임시로 저장한다 + List> bucketsTmp = buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio; + buckets = new List>(capacity); + for (int i = 0; i < capacity; i++) { + buckets.Add([]); + } + size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + foreach (List bucket in bucketsTmp) { + foreach (Pair pair in bucket) { + Put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + public void Print() { + foreach (List bucket in buckets) { + List res = []; + foreach (Pair pair in bucket) { + res.Add(pair.key + " -> " + pair.val); + } + foreach (string kv in res) { + Console.WriteLine(kv); + } + } + } + } + ``` + +=== "Go" + + ```go title="hash_map_chaining.go" + /* 체이닝 해시 테이블 */ + type hashMapChaining struct { + size int // 키-값 쌍 개수 + capacity int // 해시 테이블 용량 + loadThres float64 // 용량 확장을 수행하는 적재율 임계값 + extendRatio int // 용량 확장 배수 + buckets [][]pair // 버킷 배열 + } + + /* 생성자 */ + func newHashMapChaining() *hashMapChaining { + buckets := make([][]pair, 4) + for i := 0; i < 4; i++ { + buckets[i] = make([]pair, 0) + } + return &hashMapChaining{ + size: 0, + capacity: 4, + loadThres: 2.0 / 3.0, + extendRatio: 2, + buckets: buckets, + } + } + + /* 해시 함수 */ + func (m *hashMapChaining) hashFunc(key int) int { + return key % m.capacity + } + + /* 적재율 */ + func (m *hashMapChaining) loadFactor() float64 { + return float64(m.size) / float64(m.capacity) + } + + /* 조회 연산 */ + func (m *hashMapChaining) get(key int) string { + idx := m.hashFunc(key) + bucket := m.buckets[idx] + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for _, p := range bucket { + if p.key == key { + return p.val + } + } + // key를 찾지 못하면 빈 문자열을 반환한다 + return "" + } + + /* 추가 연산 */ + func (m *hashMapChaining) put(key int, val string) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if m.loadFactor() > m.loadThres { + m.extend() + } + idx := m.hashFunc(key) + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for i := range m.buckets[idx] { + if m.buckets[idx][i].key == key { + m.buckets[idx][i].val = val + return + } + } + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + p := pair{ + key: key, + val: val, + } + m.buckets[idx] = append(m.buckets[idx], p) + m.size += 1 + } + + /* 삭제 연산 */ + func (m *hashMapChaining) remove(key int) { + idx := m.hashFunc(key) + // 버킷을 순회하며 키-값 쌍을 삭제한다 + for i, p := range m.buckets[idx] { + if p.key == key { + // 슬라이스에서 삭제한다 + m.buckets[idx] = append(m.buckets[idx][:i], m.buckets[idx][i+1:]...) + m.size -= 1 + break + } + } + } + + /* 해시 테이블 용량 확장 */ + func (m *hashMapChaining) extend() { + // 원래 해시 테이블을 임시로 저장한다 + tmpBuckets := make([][]pair, len(m.buckets)) + for i := 0; i < len(m.buckets); i++ { + tmpBuckets[i] = make([]pair, len(m.buckets[i])) + copy(tmpBuckets[i], m.buckets[i]) + } + // 용량을 확장한 새 해시 테이블을 초기화한다 + m.capacity *= m.extendRatio + m.buckets = make([][]pair, m.capacity) + for i := 0; i < m.capacity; i++ { + m.buckets[i] = make([]pair, 0) + } + m.size = 0 + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for _, bucket := range tmpBuckets { + for _, p := range bucket { + m.put(p.key, p.val) + } + } + } + + /* 해시 테이블 출력 */ + func (m *hashMapChaining) print() { + var builder strings.Builder + + for _, bucket := range m.buckets { + builder.WriteString("[") + for _, p := range bucket { + builder.WriteString(strconv.Itoa(p.key) + " -> " + p.val + " ") + } + builder.WriteString("]") + fmt.Println(builder.String()) + builder.Reset() + } + } + ``` + +=== "Swift" + + ```swift title="hash_map_chaining.swift" + /* 체이닝 해시 테이블 */ + class HashMapChaining { + var size: Int // 키-값 쌍 개수 + var capacity: Int // 해시 테이블 용량 + var loadThres: Double // 용량 확장을 수행하는 적재율 임계값 + var extendRatio: Int // 용량 확장 배수 + var buckets: [[Pair]] // 버킷 배열 + + /* 생성자 */ + init() { + size = 0 + capacity = 4 + loadThres = 2.0 / 3.0 + extendRatio = 2 + buckets = Array(repeating: [], count: capacity) + } + + /* 해시 함수 */ + func hashFunc(key: Int) -> Int { + key % capacity + } + + /* 적재율 */ + func loadFactor() -> Double { + Double(size) / Double(capacity) + } + + /* 조회 연산 */ + func get(key: Int) -> String? { + let index = hashFunc(key: key) + let bucket = buckets[index] + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for pair in bucket { + if pair.key == key { + return pair.val + } + } + // key를 찾지 못하면 nil을 반환한다 + return nil + } + + /* 추가 연산 */ + func put(key: Int, val: String) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if loadFactor() > loadThres { + extend() + } + let index = hashFunc(key: key) + let bucket = buckets[index] + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for pair in bucket { + if pair.key == key { + pair.val = val + return + } + } + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + let pair = Pair(key: key, val: val) + buckets[index].append(pair) + size += 1 + } + + /* 삭제 연산 */ + func remove(key: Int) { + let index = hashFunc(key: key) + let bucket = buckets[index] + // 버킷을 순회하며 키-값 쌍을 삭제한다 + for (pairIndex, pair) in bucket.enumerated() { + if pair.key == key { + buckets[index].remove(at: pairIndex) + size -= 1 + break + } + } + } + + /* 해시 테이블 용량 확장 */ + func extend() { + // 원래 해시 테이블을 임시로 저장한다 + let bucketsTmp = buckets + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio + buckets = Array(repeating: [], count: capacity) + size = 0 + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for bucket in bucketsTmp { + for pair in bucket { + put(key: pair.key, val: pair.val) + } + } + } + + /* 해시 테이블 출력 */ + func print() { + for bucket in buckets { + let res = bucket.map { "\($0.key) -> \($0.val)" } + Swift.print(res) + } + } + } + ``` + +=== "JS" + + ```javascript title="hash_map_chaining.js" + /* 체이닝 해시 테이블 */ + class HashMapChaining { + #size; // 키-값 쌍 개수 + #capacity; // 해시 테이블 용량 + #loadThres; // 용량 확장을 수행하는 적재율 임계값 + #extendRatio; // 용량 확장 배수 + #buckets; // 버킷 배열 + + /* 생성자 */ + constructor() { + this.#size = 0; + this.#capacity = 4; + this.#loadThres = 2.0 / 3.0; + this.#extendRatio = 2; + this.#buckets = new Array(this.#capacity).fill(null).map((x) => []); + } + + /* 해시 함수 */ + #hashFunc(key) { + return key % this.#capacity; + } + + /* 적재율 */ + #loadFactor() { + return this.#size / this.#capacity; + } + + /* 조회 연산 */ + get(key) { + const index = this.#hashFunc(key); + const bucket = this.#buckets[index]; + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for (const pair of bucket) { + if (pair.key === key) { + return pair.val; + } + } + // key를 찾지 못하면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + put(key, val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (this.#loadFactor() > this.#loadThres) { + this.#extend(); + } + const index = this.#hashFunc(key); + const bucket = this.#buckets[index]; + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for (const pair of bucket) { + if (pair.key === key) { + pair.val = val; + return; + } + } + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + const pair = new Pair(key, val); + bucket.push(pair); + this.#size++; + } + + /* 삭제 연산 */ + remove(key) { + const index = this.#hashFunc(key); + let bucket = this.#buckets[index]; + // 버킷을 순회하며 키-값 쌍을 삭제한다 + for (let i = 0; i < bucket.length; i++) { + if (bucket[i].key === key) { + bucket.splice(i, 1); + this.#size--; + break; + } + } + } + + /* 해시 테이블 용량 확장 */ + #extend() { + // 원래 해시 테이블을 임시로 저장한다 + const bucketsTmp = this.#buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + this.#capacity *= this.#extendRatio; + this.#buckets = new Array(this.#capacity).fill(null).map((x) => []); + this.#size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (const bucket of bucketsTmp) { + for (const pair of bucket) { + this.put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + print() { + for (const bucket of this.#buckets) { + let res = []; + for (const pair of bucket) { + res.push(pair.key + ' -> ' + pair.val); + } + console.log(res); + } + } + } + ``` + +=== "TS" + + ```typescript title="hash_map_chaining.ts" + /* 체이닝 해시 테이블 */ + class HashMapChaining { + #size: number; // 키-값 쌍 개수 + #capacity: number; // 해시 테이블 용량 + #loadThres: number; // 용량 확장을 수행하는 적재율 임계값 + #extendRatio: number; // 용량 확장 배수 + #buckets: Pair[][]; // 버킷 배열 + + /* 생성자 */ + constructor() { + this.#size = 0; + this.#capacity = 4; + this.#loadThres = 2.0 / 3.0; + this.#extendRatio = 2; + this.#buckets = new Array(this.#capacity).fill(null).map((x) => []); + } + + /* 해시 함수 */ + #hashFunc(key: number): number { + return key % this.#capacity; + } + + /* 적재율 */ + #loadFactor(): number { + return this.#size / this.#capacity; + } + + /* 조회 연산 */ + get(key: number): string | null { + const index = this.#hashFunc(key); + const bucket = this.#buckets[index]; + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for (const pair of bucket) { + if (pair.key === key) { + return pair.val; + } + } + // key를 찾지 못하면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + put(key: number, val: string): void { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (this.#loadFactor() > this.#loadThres) { + this.#extend(); + } + const index = this.#hashFunc(key); + const bucket = this.#buckets[index]; + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for (const pair of bucket) { + if (pair.key === key) { + pair.val = val; + return; + } + } + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + const pair = new Pair(key, val); + bucket.push(pair); + this.#size++; + } + + /* 삭제 연산 */ + remove(key: number): void { + const index = this.#hashFunc(key); + let bucket = this.#buckets[index]; + // 버킷을 순회하며 키-값 쌍을 삭제한다 + for (let i = 0; i < bucket.length; i++) { + if (bucket[i].key === key) { + bucket.splice(i, 1); + this.#size--; + break; + } + } + } + + /* 해시 테이블 용량 확장 */ + #extend(): void { + // 원래 해시 테이블을 임시로 저장한다 + const bucketsTmp = this.#buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + this.#capacity *= this.#extendRatio; + this.#buckets = new Array(this.#capacity).fill(null).map((x) => []); + this.#size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (const bucket of bucketsTmp) { + for (const pair of bucket) { + this.put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + print(): void { + for (const bucket of this.#buckets) { + let res = []; + for (const pair of bucket) { + res.push(pair.key + ' -> ' + pair.val); + } + console.log(res); + } + } + } + ``` + +=== "Dart" + + ```dart title="hash_map_chaining.dart" + /* 체이닝 해시 테이블 */ + class HashMapChaining { + late int size; // 키-값 쌍 개수 + late int capacity; // 해시 테이블 용량 + late double loadThres; // 용량 확장을 수행하는 적재율 임계값 + late int extendRatio; // 용량 확장 배수 + late List> buckets; // 버킷 배열 + + /* 생성자 */ + HashMapChaining() { + size = 0; + capacity = 4; + loadThres = 2.0 / 3.0; + extendRatio = 2; + buckets = List.generate(capacity, (_) => []); + } + + /* 해시 함수 */ + int hashFunc(int key) { + return key % capacity; + } + + /* 적재율 */ + double loadFactor() { + return size / capacity; + } + + /* 조회 연산 */ + String? get(int key) { + int index = hashFunc(key); + List bucket = buckets[index]; + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for (Pair pair in bucket) { + if (pair.key == key) { + return pair.val; + } + } + // key를 찾지 못하면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + void put(int key, String val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (loadFactor() > loadThres) { + extend(); + } + int index = hashFunc(key); + List bucket = buckets[index]; + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for (Pair pair in bucket) { + if (pair.key == key) { + pair.val = val; + return; + } + } + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + Pair pair = Pair(key, val); + bucket.add(pair); + size++; + } + + /* 삭제 연산 */ + void remove(int key) { + int index = hashFunc(key); + List bucket = buckets[index]; + // 버킷을 순회하며 키-값 쌍을 삭제한다 + for (Pair pair in bucket) { + if (pair.key == key) { + bucket.remove(pair); + size--; + break; + } + } + } + + /* 해시 테이블 용량 확장 */ + void extend() { + // 원래 해시 테이블을 임시로 저장한다 + List> bucketsTmp = buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio; + buckets = List.generate(capacity, (_) => []); + size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (List bucket in bucketsTmp) { + for (Pair pair in bucket) { + put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + void printHashMap() { + for (List bucket in buckets) { + List res = []; + for (Pair pair in bucket) { + res.add("${pair.key} -> ${pair.val}"); + } + print(res); + } + } + } + ``` + +=== "Rust" + + ```rust title="hash_map_chaining.rs" + /* 체이닝 해시 테이블 */ + struct HashMapChaining { + size: usize, + capacity: usize, + load_thres: f32, + extend_ratio: usize, + buckets: Vec>, + } + + impl HashMapChaining { + /* 생성자 */ + fn new() -> Self { + Self { + size: 0, + capacity: 4, + load_thres: 2.0 / 3.0, + extend_ratio: 2, + buckets: vec![vec![]; 4], + } + } + + /* 해시 함수 */ + fn hash_func(&self, key: i32) -> usize { + key as usize % self.capacity + } + + /* 적재율 */ + fn load_factor(&self) -> f32 { + self.size as f32 / self.capacity as f32 + } + + /* 삭제 연산 */ + fn remove(&mut self, key: i32) -> Option { + let index = self.hash_func(key); + + // 버킷을 순회하며 키-값 쌍을 삭제한다 + for (i, p) in self.buckets[index].iter_mut().enumerate() { + if p.key == key { + let pair = self.buckets[index].remove(i); + self.size -= 1; + return Some(pair.val); + } + } + + // key를 찾지 못하면 None을 반환한다 + None + } + + /* 해시 테이블 용량 확장 */ + fn extend(&mut self) { + // 원래 해시 테이블을 임시로 저장한다 + let buckets_tmp = std::mem::take(&mut self.buckets); + + // 용량을 확장한 새 해시 테이블을 초기화한다 + self.capacity *= self.extend_ratio; + self.buckets = vec![Vec::new(); self.capacity as usize]; + self.size = 0; + + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for bucket in buckets_tmp { + for pair in bucket { + self.put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + fn print(&self) { + for bucket in &self.buckets { + let mut res = Vec::new(); + for pair in bucket { + res.push(format!("{} -> {}", pair.key, pair.val)); + } + println!("{:?}", res); + } + } + + /* 추가 연산 */ + fn put(&mut self, key: i32, val: String) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if self.load_factor() > self.load_thres { + self.extend(); + } + + let index = self.hash_func(key); + + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for pair in self.buckets[index].iter_mut() { + if pair.key == key { + pair.val = val; + return; + } + } + + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + let pair = Pair { key, val }; + self.buckets[index].push(pair); + self.size += 1; + } + + /* 조회 연산 */ + fn get(&self, key: i32) -> Option<&str> { + let index = self.hash_func(key); + + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for pair in self.buckets[index].iter() { + if pair.key == key { + return Some(&pair.val); + } + } + + // key를 찾지 못하면 None을 반환한다 + None + } + } + ``` + +=== "C" + + ```c title="hash_map_chaining.c" + /* 연결 리스트 노드 */ + typedef struct Node { + Pair *pair; + struct Node *next; + } Node; + + /* 체이닝 해시 테이블 */ + typedef struct { + int size; // 키-값 쌍 개수 + int capacity; // 해시 테이블 용량 + double loadThres; // 용량 확장을 수행하는 적재율 임계값 + int extendRatio; // 용량 확장 배수 + Node **buckets; // 버킷 배열 + } HashMapChaining; + + /* 생성자 */ + HashMapChaining *newHashMapChaining() { + HashMapChaining *hashMap = (HashMapChaining *)malloc(sizeof(HashMapChaining)); + hashMap->size = 0; + hashMap->capacity = 4; + hashMap->loadThres = 2.0 / 3.0; + hashMap->extendRatio = 2; + hashMap->buckets = (Node **)malloc(hashMap->capacity * sizeof(Node *)); + for (int i = 0; i < hashMap->capacity; i++) { + hashMap->buckets[i] = NULL; + } + return hashMap; + } + + /* 소멸자 */ + void delHashMapChaining(HashMapChaining *hashMap) { + for (int i = 0; i < hashMap->capacity; i++) { + Node *cur = hashMap->buckets[i]; + while (cur) { + Node *tmp = cur; + cur = cur->next; + free(tmp->pair); + free(tmp); + } + } + free(hashMap->buckets); + free(hashMap); + } + + /* 해시 함수 */ + int hashFunc(HashMapChaining *hashMap, int key) { + return key % hashMap->capacity; + } + + /* 적재율 */ + double loadFactor(HashMapChaining *hashMap) { + return (double)hashMap->size / (double)hashMap->capacity; + } + + /* 조회 연산 */ + char *get(HashMapChaining *hashMap, int key) { + int index = hashFunc(hashMap, key); + // 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + Node *cur = hashMap->buckets[index]; + while (cur) { + if (cur->pair->key == key) { + return cur->pair->val; + } + cur = cur->next; + } + return ""; // key를 찾지 못하면 빈 문자열을 반환한다 + } + + /* 추가 연산 */ + void put(HashMapChaining *hashMap, int key, const char *val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (loadFactor(hashMap) > hashMap->loadThres) { + extend(hashMap); + } + int index = hashFunc(hashMap, key); + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + Node *cur = hashMap->buckets[index]; + while (cur) { + if (cur->pair->key == key) { + strcpy(cur->pair->val, val); // 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + return; + } + cur = cur->next; + } + // 해당 key가 없으면 키-값 쌍을 연결 리스트의 맨 앞에 추가한다 + Pair *newPair = (Pair *)malloc(sizeof(Pair)); + newPair->key = key; + strcpy(newPair->val, val); + Node *newNode = (Node *)malloc(sizeof(Node)); + newNode->pair = newPair; + newNode->next = hashMap->buckets[index]; + hashMap->buckets[index] = newNode; + hashMap->size++; + } + + /* 해시 테이블 용량 확장 */ + void extend(HashMapChaining *hashMap) { + // 원래 해시 테이블을 임시로 저장한다 + int oldCapacity = hashMap->capacity; + Node **oldBuckets = hashMap->buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + hashMap->capacity *= hashMap->extendRatio; + hashMap->buckets = (Node **)malloc(hashMap->capacity * sizeof(Node *)); + for (int i = 0; i < hashMap->capacity; i++) { + hashMap->buckets[i] = NULL; + } + hashMap->size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (int i = 0; i < oldCapacity; i++) { + Node *cur = oldBuckets[i]; + while (cur) { + put(hashMap, cur->pair->key, cur->pair->val); + Node *temp = cur; + cur = cur->next; + // 메모리 해제 + free(temp->pair); + free(temp); + } + } + + free(oldBuckets); + } + + /* 삭제 연산 */ + void removeItem(HashMapChaining *hashMap, int key) { + int index = hashFunc(hashMap, key); + Node *cur = hashMap->buckets[index]; + Node *pre = NULL; + while (cur) { + if (cur->pair->key == key) { + // 해당 키-값 쌍을 삭제한다 + if (pre) { + pre->next = cur->next; + } else { + hashMap->buckets[index] = cur->next; + } + // 메모리 해제 + free(cur->pair); + free(cur); + hashMap->size--; + return; + } + pre = cur; + cur = cur->next; + } + } + + /* 해시 테이블 출력 */ + void print(HashMapChaining *hashMap) { + for (int i = 0; i < hashMap->capacity; i++) { + Node *cur = hashMap->buckets[i]; + printf("["); + while (cur) { + printf("%d -> %s, ", cur->pair->key, cur->pair->val); + cur = cur->next; + } + printf("]\n"); + } + } + ``` + +=== "Kotlin" + + ```kotlin title="hash_map_chaining.kt" + /* 체이닝 해시 테이블 */ + class HashMapChaining { + var size: Int // 키-값 쌍 개수 + var capacity: Int // 해시 테이블 용량 + val loadThres: Double // 용량 확장을 수행하는 적재율 임계값 + val extendRatio: Int // 용량 확장 배수 + var buckets: MutableList> // 버킷 배열 + + /* 생성자 */ + init { + size = 0 + capacity = 4 + loadThres = 2.0 / 3.0 + extendRatio = 2 + buckets = mutableListOf() + for (i in 0.. loadThres) { + extend() + } + val index = hashFunc(key) + val bucket = buckets[index] + // 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for (pair in bucket) { + if (pair.key == key) { + pair._val = _val + return + } + } + // 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + val pair = Pair(key, _val) + bucket.add(pair) + size++ + } + + /* 삭제 연산 */ + fun remove(key: Int) { + val index = hashFunc(key) + val bucket = buckets[index] + // 버킷을 순회하며 키-값 쌍을 삭제한다 + for (pair in bucket) { + if (pair.key == key) { + bucket.remove(pair) + size-- + break + } + } + } + + /* 해시 테이블 용량 확장 */ + fun extend() { + // 원래 해시 테이블을 임시로 저장한다 + val bucketsTmp = buckets + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio + // mutablelist는 크기가 고정되어 있지 않다 + buckets = mutableListOf() + for (i in 0..() + for (pair in bucket) { + val k = pair.key + val v = pair._val + res.add("$k -> $v") + } + println(res) + } + } + } + ``` + +=== "Ruby" + + ```ruby title="hash_map_chaining.rb" + ### 체이닝 해시 테이블 ### + class HashMapChaining + ### 생성자 ### + def initialize + @size = 0 # 키-값 쌍 개수 + @capacity = 4 # 해시 테이블 용량 + @load_thres = 2.0 / 3.0 # 용량 확장을 수행하는 적재율 임계값 + @extend_ratio = 2 # 용량 확장 배수 + @buckets = Array.new(@capacity) { [] } # 버킷 배열 + end + + ### 해시 함수 ### + def hash_func(key) + key % @capacity + end + + ### 적재율 ### + def load_factor + @size / @capacity + end + + ### 조회 연산 ### + def get(key) + index = hash_func(key) + bucket = @buckets[index] + # 버킷을 순회하다 key를 찾으면 해당 val을 반환한다 + for pair in bucket + return pair.val if pair.key == key + end + # key를 찾지 못하면 nil을 반환한다 + nil + end + + ### 추가 연산 ### + def put(key, val) + # 적재율이 임계값을 초과하면 용량을 확장한다 + extend if load_factor > @load_thres + index = hash_func(key) + bucket = @buckets[index] + # 버킷을 순회하다 지정한 key를 만나면 해당 val을 갱신하고 반환한다 + for pair in bucket + if pair.key == key + pair.val = val + return + end + end + # 해당 key가 없으면 키-값 쌍을 맨 뒤에 추가한다 + pair = Pair.new(key, val) + bucket << pair + @size += 1 + end + + ### 삭제 연산 ### + def remove(key) + index = hash_func(key) + bucket = @buckets[index] + # 버킷을 순회하며 키-값 쌍을 삭제한다 + for pair in bucket + if pair.key == key + bucket.delete(pair) + @size -= 1 + break + end + end + end + + ### 해시 테이블 용량 확장 ### + def extend + # 원래 해시 테이블을 임시로 저장한다 + buckets = @buckets + # 용량을 확장한 새 해시 테이블을 초기화한다 + @capacity *= @extend_ratio + @buckets = Array.new(@capacity) { [] } + @size = 0 + # 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for bucket in buckets + for pair in bucket + put(pair.key, pair.val) + end + end + end + + ### 해시 테이블 출력 ### + def print + for bucket in @buckets + res = [] + for pair in bucket + res << "#{pair.key} -> #{pair.val}" + end + pp res + end + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +연결 리스트가 매우 길어지면 $O(n)$의 조회 효율은 나빠진다. **이때 연결 리스트를 ‘AVL 트리’나 ‘레드-블랙 트리’로 바꾸면** 조회의 시간 복잡도를 $O(\log n)$으로 개선할 수 있다. + +## 6.2.2   개방 주소법 + +개방 주소법(open addressing)은 추가 자료 구조를 도입하지 않고 ‘여러 차례 탐사’하여 해시 충돌을 처리한다. 대표적인 탐사 방식에는 선형 탐사, 이차 탐사, 다중 해싱 등이 있다. + +선형 탐사를 예로 개방 주소법 해시 테이블의 작동 원리를 알아보자. + +### 1.   선형 탐사 + +선형 탐사는 고정 간격의 선형 탐색으로 탐사하며, 일반 해시 테이블과 연산 방식이 다르다. + +- **원소 삽입**: 해시 함수로 버킷 인덱스를 구한다. 이미 원소가 있으면 충돌 위치부터 뒤로 일정한 간격(보통 $1$)으로 순회하여 빈 버킷을 찾고 원소를 넣는다. +- **원소 탐색**: 해시 충돌이 있으면 같은 간격으로 뒤를 순회하다가 해당 원소를 찾으면 `value`를 반환한다. 빈 버킷을 만나면 목표 원소가 없다는 뜻이므로 `None`을 반환한다. + +아래 그림(6-6)은 개방 주소법(선형 탐사) 해시 테이블의 키-값 쌍 분포를 보여 준다. 이 해시 함수에서는 마지막 두 자리가 같은 `key`가 모두 같은 버킷에 매핑된다. 선형 탐사를 통해 이들은 해당 버킷과 그 뒤의 버킷에 차례로 저장된다. + +![개방 주소법(선형 탐사) 해시 테이블의 키-값 쌍 분포](hash_collision.assets/hash_table_linear_probing.png){ class="animation-figure" } + +

그림 6-6   개방 주소법(선형 탐사) 해시 테이블의 키-값 쌍 분포

+ +하지만 **선형 탐사에서는 ‘군집 현상’이 쉽게 발생한다**. 배열에서 연속으로 채워진 구간이 길수록 그 구간에서 충돌할 가능성도 커진다. 그 결과 군집이 더욱 커지는 악순환이 생기고, 결국 삽입, 삭제, 탐색, 갱신의 효율이 낮아진다. + +**개방 주소법 해시 테이블에서는 원소를 곧바로 삭제하면 안 된다**는 점에 주의하자. 삭제하면 배열에 빈 버킷 `None`이 생긴다. 조회 중 선형 탐사가 이 버킷에 도달하면 바로 반환하므로 그 뒤의 원소에 접근할 수 없게 된다. 아래 그림(6-7)과 같이 프로그램이 그 원소들을 존재하지 않는다고 잘못 판단할 수 있다. + +![개방 주소법에서 원소 삭제로 발생하는 조회 문제](hash_collision.assets/hash_table_open_addressing_deletion.png){ class="animation-figure" } + +

그림 6-7   개방 주소법에서 원소 삭제로 발생하는 조회 문제

+ +이 문제는 지연 삭제(lazy deletion)로 해결할 수 있다. 원소를 해시 테이블에서 곧바로 제거하지 않고 **상수 `TOMBSTONE`으로 해당 버킷을 표시한다**. `None`과 `TOMBSTONE`은 모두 빈 버킷을 나타내며 새 키-값 쌍을 넣을 수 있다. 하지만 `TOMBSTONE`을 만나면 그 뒤에도 키-값 쌍이 있을 수 있으므로 탐사를 계속해야 한다. + +그러나 **지연 삭제는 해시 테이블의 성능 저하를 가속할 수 있다**. 삭제할 때마다 삭제 표시가 생기므로 `TOMBSTONE`이 늘어날수록 탐색 시간도 길어진다. 목표 원소를 찾기 위해 여러 `TOMBSTONE`을 건너뛰어야 할 수 있기 때문이다. + +이를 위해 선형 탐사 중 처음 만난 `TOMBSTONE`의 인덱스를 기록하고, 찾은 목표 원소와 그 `TOMBSTONE`의 위치를 교환하는 방법을 생각할 수 있다. 조회하거나 추가할 때마다 원소가 이상적인 위치(탐사 시작점)에 더 가까운 버킷으로 옮겨지므로 조회 효율이 좋아진다. + +다음 코드는 지연 삭제를 포함한 개방 주소법(선형 탐사) 해시 테이블을 구현한다. 공간을 더 충분히 활용하기 위해 해시 테이블을 ‘원형 배열’로 보고, 끝을 넘으면 처음으로 돌아와 순회를 계속한다. + +=== "Python" + + ```python title="hash_map_open_addressing.py" + class HashMapOpenAddressing: + """개방 주소법 해시 테이블""" + + def __init__(self): + """생성자""" + self.size = 0 # 키-값 쌍 개수 + self.capacity = 4 # 해시 테이블 용량 + self.load_thres = 2.0 / 3.0 # 용량 확장을 수행하는 적재율 임계값 + self.extend_ratio = 2 # 용량 확장 배수 + self.buckets: list[Pair | None] = [None] * self.capacity # 버킷 배열 + self.TOMBSTONE = Pair(-1, "-1") # 삭제 표시 + + def hash_func(self, key: int) -> int: + """해시 함수""" + return key % self.capacity + + def load_factor(self) -> float: + """적재율""" + return self.size / self.capacity + + def find_bucket(self, key: int) -> int: + """key에 해당하는 버킷 인덱스 탐색""" + index = self.hash_func(key) + first_tombstone = -1 + # 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while self.buckets[index] is not None: + # key를 만나면 해당 버킷 인덱스를 반환한다 + if self.buckets[index].key == key: + # 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if first_tombstone != -1: + self.buckets[first_tombstone] = self.buckets[index] + self.buckets[index] = self.TOMBSTONE + return first_tombstone # 옮긴 후의 버킷 인덱스를 반환한다 + return index # 버킷 인덱스를 반환한다 + # 처음 만난 삭제 표시를 기록한다 + if first_tombstone == -1 and self.buckets[index] is self.TOMBSTONE: + first_tombstone = index + # 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % self.capacity + # key가 없으면 추가할 위치의 인덱스를 반환한다 + return index if first_tombstone == -1 else first_tombstone + + def get(self, key: int) -> str: + """조회 연산""" + # key에 해당하는 버킷 인덱스 탐색 + index = self.find_bucket(key) + # 키-값 쌍을 찾으면 해당 val을 반환한다 + if self.buckets[index] not in [None, self.TOMBSTONE]: + return self.buckets[index].val + # 키-값 쌍이 없으면 None을 반환한다 + return None + + def put(self, key: int, val: str): + """추가 연산""" + # 적재율이 임계값을 초과하면 용량을 확장한다 + if self.load_factor() > self.load_thres: + self.extend() + # key에 해당하는 버킷 인덱스 탐색 + index = self.find_bucket(key) + # 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if self.buckets[index] not in [None, self.TOMBSTONE]: + self.buckets[index].val = val + return + # 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + self.buckets[index] = Pair(key, val) + self.size += 1 + + def remove(self, key: int): + """삭제 연산""" + # key에 해당하는 버킷 인덱스 탐색 + index = self.find_bucket(key) + # 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if self.buckets[index] not in [None, self.TOMBSTONE]: + self.buckets[index] = self.TOMBSTONE + self.size -= 1 + + def extend(self): + """해시 테이블 용량 확장""" + # 원래 해시 테이블을 임시로 저장한다 + buckets_tmp = self.buckets + # 용량을 확장한 새 해시 테이블을 초기화한다 + self.capacity *= self.extend_ratio + self.buckets = [None] * self.capacity + self.size = 0 + # 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for pair in buckets_tmp: + if pair not in [None, self.TOMBSTONE]: + self.put(pair.key, pair.val) + + def print(self): + """해시 테이블 출력""" + for pair in self.buckets: + if pair is None: + print("None") + elif pair is self.TOMBSTONE: + print("TOMBSTONE") + else: + print(pair.key, "->", pair.val) + ``` + +=== "C++" + + ```cpp title="hash_map_open_addressing.cpp" + /* 개방 주소법 해시 테이블 */ + class HashMapOpenAddressing { + private: + int size; // 키-값 쌍 개수 + int capacity = 4; // 해시 테이블 용량 + const double loadThres = 2.0 / 3.0; // 용량 확장을 수행하는 적재율 임계값 + const int extendRatio = 2; // 용량 확장 배수 + vector buckets; // 버킷 배열 + Pair *TOMBSTONE = new Pair(-1, "-1"); // 삭제 표시 + + public: + /* 생성자 */ + HashMapOpenAddressing() : size(0), buckets(capacity, nullptr) { + } + + /* 소멸자 */ + ~HashMapOpenAddressing() { + for (Pair *pair : buckets) { + if (pair != nullptr && pair != TOMBSTONE) { + delete pair; + } + } + delete TOMBSTONE; + } + + /* 해시 함수 */ + int hashFunc(int key) { + return key % capacity; + } + + /* 적재율 */ + double loadFactor() { + return (double)size / capacity; + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + int findBucket(int key) { + int index = hashFunc(key); + int firstTombstone = -1; + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while (buckets[index] != nullptr) { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if (buckets[index]->key == key) { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if (firstTombstone != -1) { + buckets[firstTombstone] = buckets[index]; + buckets[index] = TOMBSTONE; + return firstTombstone; // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index; // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if (firstTombstone == -1 && buckets[index] == TOMBSTONE) { + firstTombstone = index; + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % capacity; + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + return firstTombstone == -1 ? index : firstTombstone; + } + + /* 조회 연산 */ + string get(int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(key); + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) { + return buckets[index]->val; + } + // 키-값 쌍이 없으면 빈 문자열을 반환한다 + return ""; + } + + /* 추가 연산 */ + void put(int key, string val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (loadFactor() > loadThres) { + extend(); + } + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(key); + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) { + buckets[index]->val = val; + return; + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + buckets[index] = new Pair(key, val); + size++; + } + + /* 삭제 연산 */ + void remove(int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(key); + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) { + delete buckets[index]; + buckets[index] = TOMBSTONE; + size--; + } + } + + /* 해시 테이블 용량 확장 */ + void extend() { + // 원래 해시 테이블을 임시로 저장한다 + vector bucketsTmp = buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio; + buckets = vector(capacity, nullptr); + size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (Pair *pair : bucketsTmp) { + if (pair != nullptr && pair != TOMBSTONE) { + put(pair->key, pair->val); + delete pair; + } + } + } + + /* 해시 테이블 출력 */ + void print() { + for (Pair *pair : buckets) { + if (pair == nullptr) { + cout << "nullptr" << endl; + } else if (pair == TOMBSTONE) { + cout << "TOMBSTONE" << endl; + } else { + cout << pair->key << " -> " << pair->val << endl; + } + } + } + }; + ``` + +=== "Java" + + ```java title="hash_map_open_addressing.java" + /* 개방 주소법 해시 테이블 */ + class HashMapOpenAddressing { + private int size; // 키-값 쌍 개수 + private int capacity = 4; // 해시 테이블 용량 + private final double loadThres = 2.0 / 3.0; // 용량 확장을 수행하는 적재율 임계값 + private final int extendRatio = 2; // 용량 확장 배수 + private Pair[] buckets; // 버킷 배열 + private final Pair TOMBSTONE = new Pair(-1, "-1"); // 삭제 표시 + + /* 생성자 */ + public HashMapOpenAddressing() { + size = 0; + buckets = new Pair[capacity]; + } + + /* 해시 함수 */ + private int hashFunc(int key) { + return key % capacity; + } + + /* 적재율 */ + private double loadFactor() { + return (double) size / capacity; + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + private int findBucket(int key) { + int index = hashFunc(key); + int firstTombstone = -1; + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while (buckets[index] != null) { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if (buckets[index].key == key) { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if (firstTombstone != -1) { + buckets[firstTombstone] = buckets[index]; + buckets[index] = TOMBSTONE; + return firstTombstone; // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index; // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if (firstTombstone == -1 && buckets[index] == TOMBSTONE) { + firstTombstone = index; + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % capacity; + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + return firstTombstone == -1 ? index : firstTombstone; + } + + /* 조회 연산 */ + public String get(int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(key); + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if (buckets[index] != null && buckets[index] != TOMBSTONE) { + return buckets[index].val; + } + // 키-값 쌍이 없으면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + public void put(int key, String val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (loadFactor() > loadThres) { + extend(); + } + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(key); + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if (buckets[index] != null && buckets[index] != TOMBSTONE) { + buckets[index].val = val; + return; + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + buckets[index] = new Pair(key, val); + size++; + } + + /* 삭제 연산 */ + public void remove(int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(key); + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if (buckets[index] != null && buckets[index] != TOMBSTONE) { + buckets[index] = TOMBSTONE; + size--; + } + } + + /* 해시 테이블 용량 확장 */ + private void extend() { + // 원래 해시 테이블을 임시로 저장한다 + Pair[] bucketsTmp = buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio; + buckets = new Pair[capacity]; + size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (Pair pair : bucketsTmp) { + if (pair != null && pair != TOMBSTONE) { + put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + public void print() { + for (Pair pair : buckets) { + if (pair == null) { + System.out.println("null"); + } else if (pair == TOMBSTONE) { + System.out.println("TOMBSTONE"); + } else { + System.out.println(pair.key + " -> " + pair.val); + } + } + } + } + ``` + +=== "C#" + + ```csharp title="hash_map_open_addressing.cs" + /* 개방 주소법 해시 테이블 */ + class HashMapOpenAddressing { + int size; // 키-값 쌍 개수 + int capacity = 4; // 해시 테이블 용량 + double loadThres = 2.0 / 3.0; // 용량 확장을 수행하는 적재율 임계값 + int extendRatio = 2; // 용량 확장 배수 + Pair[] buckets; // 버킷 배열 + Pair TOMBSTONE = new(-1, "-1"); // 삭제 표시 + + /* 생성자 */ + public HashMapOpenAddressing() { + size = 0; + buckets = new Pair[capacity]; + } + + /* 해시 함수 */ + int HashFunc(int key) { + return key % capacity; + } + + /* 적재율 */ + double LoadFactor() { + return (double)size / capacity; + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + int FindBucket(int key) { + int index = HashFunc(key); + int firstTombstone = -1; + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while (buckets[index] != null) { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if (buckets[index].key == key) { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if (firstTombstone != -1) { + buckets[firstTombstone] = buckets[index]; + buckets[index] = TOMBSTONE; + return firstTombstone; // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index; // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if (firstTombstone == -1 && buckets[index] == TOMBSTONE) { + firstTombstone = index; + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % capacity; + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + return firstTombstone == -1 ? index : firstTombstone; + } + + /* 조회 연산 */ + public string? Get(int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = FindBucket(key); + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if (buckets[index] != null && buckets[index] != TOMBSTONE) { + return buckets[index].val; + } + // 키-값 쌍이 없으면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + public void Put(int key, string val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (LoadFactor() > loadThres) { + Extend(); + } + // key에 해당하는 버킷 인덱스 탐색 + int index = FindBucket(key); + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if (buckets[index] != null && buckets[index] != TOMBSTONE) { + buckets[index].val = val; + return; + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + buckets[index] = new Pair(key, val); + size++; + } + + /* 삭제 연산 */ + public void Remove(int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = FindBucket(key); + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if (buckets[index] != null && buckets[index] != TOMBSTONE) { + buckets[index] = TOMBSTONE; + size--; + } + } + + /* 해시 테이블 용량 확장 */ + void Extend() { + // 원래 해시 테이블을 임시로 저장한다 + Pair[] bucketsTmp = buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio; + buckets = new Pair[capacity]; + size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + foreach (Pair pair in bucketsTmp) { + if (pair != null && pair != TOMBSTONE) { + Put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + public void Print() { + foreach (Pair pair in buckets) { + if (pair == null) { + Console.WriteLine("null"); + } else if (pair == TOMBSTONE) { + Console.WriteLine("TOMBSTONE"); + } else { + Console.WriteLine(pair.key + " -> " + pair.val); + } + } + } + } + ``` + +=== "Go" + + ```go title="hash_map_open_addressing.go" + /* 개방 주소법 해시 테이블 */ + type hashMapOpenAddressing struct { + size int // 키-값 쌍 개수 + capacity int // 해시 테이블 용량 + loadThres float64 // 용량 확장을 수행하는 적재율 임계값 + extendRatio int // 용량 확장 배수 + buckets []*pair // 버킷 배열 + TOMBSTONE *pair // 삭제 표시 + } + + /* 생성자 */ + func newHashMapOpenAddressing() *hashMapOpenAddressing { + return &hashMapOpenAddressing{ + size: 0, + capacity: 4, + loadThres: 2.0 / 3.0, + extendRatio: 2, + buckets: make([]*pair, 4), + TOMBSTONE: &pair{-1, "-1"}, + } + } + + /* 해시 함수 */ + func (h *hashMapOpenAddressing) hashFunc(key int) int { + return key % h.capacity // 키를 바탕으로 해시값을 계산한다 + } + + /* 적재율 */ + func (h *hashMapOpenAddressing) loadFactor() float64 { + return float64(h.size) / float64(h.capacity) // 현재 적재율을 계산한다 + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + func (h *hashMapOpenAddressing) findBucket(key int) int { + index := h.hashFunc(key) // 초기 인덱스를 구한다 + firstTombstone := -1 // 처음 만난 TOMBSTONE의 위치를 기록한다 + for h.buckets[index] != nil { + if h.buckets[index].key == key { + if firstTombstone != -1 { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + h.buckets[firstTombstone] = h.buckets[index] + h.buckets[index] = h.TOMBSTONE + return firstTombstone // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index // 찾은 인덱스를 반환한다 + } + if firstTombstone == -1 && h.buckets[index] == h.TOMBSTONE { + firstTombstone = index // 처음 만난 삭제 표시의 위치를 기록한다 + } + index = (index + 1) % h.capacity // 선형 탐사를 수행하며, 끝을 넘어가면 처음으로 돌아온다 + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + if firstTombstone != -1 { + return firstTombstone + } + return index + } + + /* 조회 연산 */ + func (h *hashMapOpenAddressing) get(key int) string { + index := h.findBucket(key) // key에 해당하는 버킷 인덱스 탐색 + if h.buckets[index] != nil && h.buckets[index] != h.TOMBSTONE { + return h.buckets[index].val // 키-값 쌍을 찾으면 해당 val을 반환한다 + } + return "" // 키-값 쌍이 없으면 ""를 반환한다 + } + + /* 추가 연산 */ + func (h *hashMapOpenAddressing) put(key int, val string) { + if h.loadFactor() > h.loadThres { + h.extend() // 적재율이 임계값을 초과하면 용량을 확장한다 + } + index := h.findBucket(key) // key에 해당하는 버킷 인덱스 탐색 + if h.buckets[index] == nil || h.buckets[index] == h.TOMBSTONE { + h.buckets[index] = &pair{key, val} // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + h.size++ + } else { + h.buckets[index].val = val // 키-값 쌍을 찾으면 val을 덮어쓴다 + } + } + + /* 삭제 연산 */ + func (h *hashMapOpenAddressing) remove(key int) { + index := h.findBucket(key) // key에 해당하는 버킷 인덱스 탐색 + if h.buckets[index] != nil && h.buckets[index] != h.TOMBSTONE { + h.buckets[index] = h.TOMBSTONE // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + h.size-- + } + } + + /* 해시 테이블 용량 확장 */ + func (h *hashMapOpenAddressing) extend() { + oldBuckets := h.buckets // 원래 해시 테이블을 임시로 저장한다 + h.capacity *= h.extendRatio // 용량을 갱신한다 + h.buckets = make([]*pair, h.capacity) // 용량을 확장한 새 해시 테이블을 초기화한다 + h.size = 0 // 크기를 초기화한다 + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for _, pair := range oldBuckets { + if pair != nil && pair != h.TOMBSTONE { + h.put(pair.key, pair.val) + } + } + } + + /* 해시 테이블 출력 */ + func (h *hashMapOpenAddressing) print() { + for _, pair := range h.buckets { + if pair == nil { + fmt.Println("nil") + } else if pair == h.TOMBSTONE { + fmt.Println("TOMBSTONE") + } else { + fmt.Printf("%d -> %s\n", pair.key, pair.val) + } + } + } + ``` + +=== "Swift" + + ```swift title="hash_map_open_addressing.swift" + /* 개방 주소법 해시 테이블 */ + class HashMapOpenAddressing { + var size: Int // 키-값 쌍 개수 + var capacity: Int // 해시 테이블 용량 + var loadThres: Double // 용량 확장을 수행하는 적재율 임계값 + var extendRatio: Int // 용량 확장 배수 + var buckets: [Pair?] // 버킷 배열 + var TOMBSTONE: Pair // 삭제 표시 + + /* 생성자 */ + init() { + size = 0 + capacity = 4 + loadThres = 2.0 / 3.0 + extendRatio = 2 + buckets = Array(repeating: nil, count: capacity) + TOMBSTONE = Pair(key: -1, val: "-1") + } + + /* 해시 함수 */ + func hashFunc(key: Int) -> Int { + key % capacity + } + + /* 적재율 */ + func loadFactor() -> Double { + Double(size) / Double(capacity) + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + func findBucket(key: Int) -> Int { + var index = hashFunc(key: key) + var firstTombstone = -1 + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while buckets[index] != nil { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if buckets[index]!.key == key { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if firstTombstone != -1 { + buckets[firstTombstone] = buckets[index] + buckets[index] = TOMBSTONE + return firstTombstone // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if firstTombstone == -1 && buckets[index] == TOMBSTONE { + firstTombstone = index + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % capacity + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + return firstTombstone == -1 ? index : firstTombstone + } + + /* 조회 연산 */ + func get(key: Int) -> String? { + // key에 해당하는 버킷 인덱스 탐색 + let index = findBucket(key: key) + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if buckets[index] != nil, buckets[index] != TOMBSTONE { + return buckets[index]!.val + } + // 키-값 쌍이 없으면 null을 반환한다 + return nil + } + + /* 추가 연산 */ + func put(key: Int, val: String) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if loadFactor() > loadThres { + extend() + } + // key에 해당하는 버킷 인덱스 탐색 + let index = findBucket(key: key) + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if buckets[index] != nil, buckets[index] != TOMBSTONE { + buckets[index]!.val = val + return + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + buckets[index] = Pair(key: key, val: val) + size += 1 + } + + /* 삭제 연산 */ + func remove(key: Int) { + // key에 해당하는 버킷 인덱스 탐색 + let index = findBucket(key: key) + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if buckets[index] != nil, buckets[index] != TOMBSTONE { + buckets[index] = TOMBSTONE + size -= 1 + } + } + + /* 해시 테이블 용량 확장 */ + func extend() { + // 원래 해시 테이블을 임시로 저장한다 + let bucketsTmp = buckets + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio + buckets = Array(repeating: nil, count: capacity) + size = 0 + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for pair in bucketsTmp { + if let pair, pair != TOMBSTONE { + put(key: pair.key, val: pair.val) + } + } + } + + /* 해시 테이블 출력 */ + func print() { + for pair in buckets { + if pair == nil { + Swift.print("null") + } else if pair == TOMBSTONE { + Swift.print("TOMBSTONE") + } else { + Swift.print("\(pair!.key) -> \(pair!.val)") + } + } + } + } + ``` + +=== "JS" + + ```javascript title="hash_map_open_addressing.js" + /* 개방 주소법 해시 테이블 */ + class HashMapOpenAddressing { + #size; // 키-값 쌍 개수 + #capacity; // 해시 테이블 용량 + #loadThres; // 용량 확장을 수행하는 적재율 임계값 + #extendRatio; // 용량 확장 배수 + #buckets; // 버킷 배열 + #TOMBSTONE; // 삭제 표시 + + /* 생성자 */ + constructor() { + this.#size = 0; // 키-값 쌍 개수 + this.#capacity = 4; // 해시 테이블 용량 + this.#loadThres = 2.0 / 3.0; // 용량 확장을 수행하는 적재율 임계값 + this.#extendRatio = 2; // 용량 확장 배수 + this.#buckets = Array(this.#capacity).fill(null); // 버킷 배열 + this.#TOMBSTONE = new Pair(-1, '-1'); // 삭제 표시 + } + + /* 해시 함수 */ + #hashFunc(key) { + return key % this.#capacity; + } + + /* 적재율 */ + #loadFactor() { + return this.#size / this.#capacity; + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + #findBucket(key) { + let index = this.#hashFunc(key); + let firstTombstone = -1; + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while (this.#buckets[index] !== null) { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if (this.#buckets[index].key === key) { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if (firstTombstone !== -1) { + this.#buckets[firstTombstone] = this.#buckets[index]; + this.#buckets[index] = this.#TOMBSTONE; + return firstTombstone; // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index; // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if ( + firstTombstone === -1 && + this.#buckets[index] === this.#TOMBSTONE + ) { + firstTombstone = index; + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % this.#capacity; + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + return firstTombstone === -1 ? index : firstTombstone; + } + + /* 조회 연산 */ + get(key) { + // key에 해당하는 버킷 인덱스 탐색 + const index = this.#findBucket(key); + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if ( + this.#buckets[index] !== null && + this.#buckets[index] !== this.#TOMBSTONE + ) { + return this.#buckets[index].val; + } + // 키-값 쌍이 없으면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + put(key, val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (this.#loadFactor() > this.#loadThres) { + this.#extend(); + } + // key에 해당하는 버킷 인덱스 탐색 + const index = this.#findBucket(key); + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if ( + this.#buckets[index] !== null && + this.#buckets[index] !== this.#TOMBSTONE + ) { + this.#buckets[index].val = val; + return; + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + this.#buckets[index] = new Pair(key, val); + this.#size++; + } + + /* 삭제 연산 */ + remove(key) { + // key에 해당하는 버킷 인덱스 탐색 + const index = this.#findBucket(key); + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if ( + this.#buckets[index] !== null && + this.#buckets[index] !== this.#TOMBSTONE + ) { + this.#buckets[index] = this.#TOMBSTONE; + this.#size--; + } + } + + /* 해시 테이블 용량 확장 */ + #extend() { + // 원래 해시 테이블을 임시로 저장한다 + const bucketsTmp = this.#buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + this.#capacity *= this.#extendRatio; + this.#buckets = Array(this.#capacity).fill(null); + this.#size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (const pair of bucketsTmp) { + if (pair !== null && pair !== this.#TOMBSTONE) { + this.put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + print() { + for (const pair of this.#buckets) { + if (pair === null) { + console.log('null'); + } else if (pair === this.#TOMBSTONE) { + console.log('TOMBSTONE'); + } else { + console.log(pair.key + ' -> ' + pair.val); + } + } + } + } + ``` + +=== "TS" + + ```typescript title="hash_map_open_addressing.ts" + /* 개방 주소법 해시 테이블 */ + class HashMapOpenAddressing { + private size: number; // 키-값 쌍 개수 + private capacity: number; // 해시 테이블 용량 + private loadThres: number; // 용량 확장을 수행하는 적재율 임계값 + private extendRatio: number; // 용량 확장 배수 + private buckets: Array; // 버킷 배열 + private TOMBSTONE: Pair; // 삭제 표시 + + /* 생성자 */ + constructor() { + this.size = 0; // 키-값 쌍 개수 + this.capacity = 4; // 해시 테이블 용량 + this.loadThres = 2.0 / 3.0; // 용량 확장을 수행하는 적재율 임계값 + this.extendRatio = 2; // 용량 확장 배수 + this.buckets = Array(this.capacity).fill(null); // 버킷 배열 + this.TOMBSTONE = new Pair(-1, '-1'); // 삭제 표시 + } + + /* 해시 함수 */ + private hashFunc(key: number): number { + return key % this.capacity; + } + + /* 적재율 */ + private loadFactor(): number { + return this.size / this.capacity; + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + private findBucket(key: number): number { + let index = this.hashFunc(key); + let firstTombstone = -1; + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while (this.buckets[index] !== null) { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if (this.buckets[index]!.key === key) { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if (firstTombstone !== -1) { + this.buckets[firstTombstone] = this.buckets[index]; + this.buckets[index] = this.TOMBSTONE; + return firstTombstone; // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index; // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if ( + firstTombstone === -1 && + this.buckets[index] === this.TOMBSTONE + ) { + firstTombstone = index; + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % this.capacity; + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + return firstTombstone === -1 ? index : firstTombstone; + } + + /* 조회 연산 */ + get(key: number): string | null { + // key에 해당하는 버킷 인덱스 탐색 + const index = this.findBucket(key); + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if ( + this.buckets[index] !== null && + this.buckets[index] !== this.TOMBSTONE + ) { + return this.buckets[index]!.val; + } + // 키-값 쌍이 없으면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + put(key: number, val: string): void { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (this.loadFactor() > this.loadThres) { + this.extend(); + } + // key에 해당하는 버킷 인덱스 탐색 + const index = this.findBucket(key); + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if ( + this.buckets[index] !== null && + this.buckets[index] !== this.TOMBSTONE + ) { + this.buckets[index]!.val = val; + return; + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + this.buckets[index] = new Pair(key, val); + this.size++; + } + + /* 삭제 연산 */ + remove(key: number): void { + // key에 해당하는 버킷 인덱스 탐색 + const index = this.findBucket(key); + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if ( + this.buckets[index] !== null && + this.buckets[index] !== this.TOMBSTONE + ) { + this.buckets[index] = this.TOMBSTONE; + this.size--; + } + } + + /* 해시 테이블 용량 확장 */ + private extend(): void { + // 원래 해시 테이블을 임시로 저장한다 + const bucketsTmp = this.buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + this.capacity *= this.extendRatio; + this.buckets = Array(this.capacity).fill(null); + this.size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (const pair of bucketsTmp) { + if (pair !== null && pair !== this.TOMBSTONE) { + this.put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + print(): void { + for (const pair of this.buckets) { + if (pair === null) { + console.log('null'); + } else if (pair === this.TOMBSTONE) { + console.log('TOMBSTONE'); + } else { + console.log(pair.key + ' -> ' + pair.val); + } + } + } + } + ``` + +=== "Dart" + + ```dart title="hash_map_open_addressing.dart" + /* 개방 주소법 해시 테이블 */ + class HashMapOpenAddressing { + late int _size; // 키-값 쌍 개수 + int _capacity = 4; // 해시 테이블 용량 + double _loadThres = 2.0 / 3.0; // 용량 확장을 수행하는 적재율 임계값 + int _extendRatio = 2; // 용량 확장 배수 + late List _buckets; // 버킷 배열 + Pair _TOMBSTONE = Pair(-1, "-1"); // 삭제 표시 + + /* 생성자 */ + HashMapOpenAddressing() { + _size = 0; + _buckets = List.generate(_capacity, (index) => null); + } + + /* 해시 함수 */ + int hashFunc(int key) { + return key % _capacity; + } + + /* 적재율 */ + double loadFactor() { + return _size / _capacity; + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + int findBucket(int key) { + int index = hashFunc(key); + int firstTombstone = -1; + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while (_buckets[index] != null) { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if (_buckets[index]!.key == key) { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if (firstTombstone != -1) { + _buckets[firstTombstone] = _buckets[index]; + _buckets[index] = _TOMBSTONE; + return firstTombstone; // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index; // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if (firstTombstone == -1 && _buckets[index] == _TOMBSTONE) { + firstTombstone = index; + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % _capacity; + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + return firstTombstone == -1 ? index : firstTombstone; + } + + /* 조회 연산 */ + String? get(int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(key); + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if (_buckets[index] != null && _buckets[index] != _TOMBSTONE) { + return _buckets[index]!.val; + } + // 키-값 쌍이 없으면 null을 반환한다 + return null; + } + + /* 추가 연산 */ + void put(int key, String val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (loadFactor() > _loadThres) { + extend(); + } + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(key); + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if (_buckets[index] != null && _buckets[index] != _TOMBSTONE) { + _buckets[index]!.val = val; + return; + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + _buckets[index] = new Pair(key, val); + _size++; + } + + /* 삭제 연산 */ + void remove(int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(key); + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if (_buckets[index] != null && _buckets[index] != _TOMBSTONE) { + _buckets[index] = _TOMBSTONE; + _size--; + } + } + + /* 해시 테이블 용량 확장 */ + void extend() { + // 원래 해시 테이블을 임시로 저장한다 + List bucketsTmp = _buckets; + // 용량을 확장한 새 해시 테이블을 초기화한다 + _capacity *= _extendRatio; + _buckets = List.generate(_capacity, (index) => null); + _size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (Pair? pair in bucketsTmp) { + if (pair != null && pair != _TOMBSTONE) { + put(pair.key, pair.val); + } + } + } + + /* 해시 테이블 출력 */ + void printHashMap() { + for (Pair? pair in _buckets) { + if (pair == null) { + print("null"); + } else if (pair == _TOMBSTONE) { + print("TOMBSTONE"); + } else { + print("${pair.key} -> ${pair.val}"); + } + } + } + } + ``` + +=== "Rust" + + ```rust title="hash_map_open_addressing.rs" + /* 개방 주소법 해시 테이블 */ + struct HashMapOpenAddressing { + size: usize, // 키-값 쌍 개수 + capacity: usize, // 해시 테이블 용량 + load_thres: f64, // 용량 확장을 수행하는 적재율 임계값 + extend_ratio: usize, // 용량 확장 배수 + buckets: Vec>, // 버킷 배열 + TOMBSTONE: Option, // 삭제 표시 + } + + impl HashMapOpenAddressing { + /* 생성자 */ + fn new() -> Self { + Self { + size: 0, + capacity: 4, + load_thres: 2.0 / 3.0, + extend_ratio: 2, + buckets: vec![None; 4], + TOMBSTONE: Some(Pair { + key: -1, + val: "-1".to_string(), + }), + } + } + + /* 해시 함수 */ + fn hash_func(&self, key: i32) -> usize { + (key % self.capacity as i32) as usize + } + + /* 적재율 */ + fn load_factor(&self) -> f64 { + self.size as f64 / self.capacity as f64 + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + fn find_bucket(&mut self, key: i32) -> usize { + let mut index = self.hash_func(key); + let mut first_tombstone = -1; + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while self.buckets[index].is_some() { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if self.buckets[index].as_ref().unwrap().key == key { + // 앞서 삭제 표시를 만났다면 키-값 쌍을 해당 인덱스로 옮긴다 + if first_tombstone != -1 { + self.buckets[first_tombstone as usize] = self.buckets[index].take(); + self.buckets[index] = self.TOMBSTONE.clone(); + return first_tombstone as usize; // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index; // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if first_tombstone == -1 && self.buckets[index] == self.TOMBSTONE { + first_tombstone = index as i32; + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % self.capacity; + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + if first_tombstone == -1 { + index + } else { + first_tombstone as usize + } + } + + /* 조회 연산 */ + fn get(&mut self, key: i32) -> Option<&str> { + // key에 해당하는 버킷 인덱스 탐색 + let index = self.find_bucket(key); + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if self.buckets[index].is_some() && self.buckets[index] != self.TOMBSTONE { + return self.buckets[index].as_ref().map(|pair| &pair.val as &str); + } + // 키-값 쌍이 없으면 null을 반환한다 + None + } + + /* 추가 연산 */ + fn put(&mut self, key: i32, val: String) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if self.load_factor() > self.load_thres { + self.extend(); + } + // key에 해당하는 버킷 인덱스 탐색 + let index = self.find_bucket(key); + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if self.buckets[index].is_some() && self.buckets[index] != self.TOMBSTONE { + self.buckets[index].as_mut().unwrap().val = val; + return; + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + self.buckets[index] = Some(Pair { key, val }); + self.size += 1; + } + + /* 삭제 연산 */ + fn remove(&mut self, key: i32) { + // key에 해당하는 버킷 인덱스 탐색 + let index = self.find_bucket(key); + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if self.buckets[index].is_some() && self.buckets[index] != self.TOMBSTONE { + self.buckets[index] = self.TOMBSTONE.clone(); + self.size -= 1; + } + } + + /* 해시 테이블 용량 확장 */ + fn extend(&mut self) { + // 원래 해시 테이블을 임시로 저장한다 + let buckets_tmp = self.buckets.clone(); + // 용량을 확장한 새 해시 테이블을 초기화한다 + self.capacity *= self.extend_ratio; + self.buckets = vec![None; self.capacity]; + self.size = 0; + + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for pair in buckets_tmp { + if pair.is_none() || pair == self.TOMBSTONE { + continue; + } + let pair = pair.unwrap(); + + self.put(pair.key, pair.val); + } + } + /* 해시 테이블 출력 */ + fn print(&self) { + for pair in &self.buckets { + if pair.is_none() { + println!("null"); + } else if pair == &self.TOMBSTONE { + println!("TOMBSTONE"); + } else { + let pair = pair.as_ref().unwrap(); + println!("{} -> {}", pair.key, pair.val); + } + } + } + } + ``` + +=== "C" + + ```c title="hash_map_open_addressing.c" + /* 개방 주소법 해시 테이블 */ + typedef struct { + int size; // 키-값 쌍 개수 + int capacity; // 해시 테이블 용량 + double loadThres; // 용량 확장을 수행하는 적재율 임계값 + int extendRatio; // 용량 확장 배수 + Pair **buckets; // 버킷 배열 + Pair *TOMBSTONE; // 삭제 표시 + } HashMapOpenAddressing; + + /* 생성자 */ + HashMapOpenAddressing *newHashMapOpenAddressing() { + HashMapOpenAddressing *hashMap = (HashMapOpenAddressing *)malloc(sizeof(HashMapOpenAddressing)); + hashMap->size = 0; + hashMap->capacity = 4; + hashMap->loadThres = 2.0 / 3.0; + hashMap->extendRatio = 2; + hashMap->buckets = (Pair **)calloc(hashMap->capacity, sizeof(Pair *)); + hashMap->TOMBSTONE = (Pair *)malloc(sizeof(Pair)); + hashMap->TOMBSTONE->key = -1; + hashMap->TOMBSTONE->val = "-1"; + + return hashMap; + } + + /* 소멸자 */ + void delHashMapOpenAddressing(HashMapOpenAddressing *hashMap) { + for (int i = 0; i < hashMap->capacity; i++) { + Pair *pair = hashMap->buckets[i]; + if (pair != NULL && pair != hashMap->TOMBSTONE) { + free(pair->val); + free(pair); + } + } + free(hashMap->buckets); + free(hashMap->TOMBSTONE); + free(hashMap); + } + + /* 해시 함수 */ + int hashFunc(HashMapOpenAddressing *hashMap, int key) { + return key % hashMap->capacity; + } + + /* 적재율 */ + double loadFactor(HashMapOpenAddressing *hashMap) { + return (double)hashMap->size / (double)hashMap->capacity; + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + int findBucket(HashMapOpenAddressing *hashMap, int key) { + int index = hashFunc(hashMap, key); + int firstTombstone = -1; + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while (hashMap->buckets[index] != NULL) { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if (hashMap->buckets[index]->key == key) { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if (firstTombstone != -1) { + hashMap->buckets[firstTombstone] = hashMap->buckets[index]; + hashMap->buckets[index] = hashMap->TOMBSTONE; + return firstTombstone; // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index; // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if (firstTombstone == -1 && hashMap->buckets[index] == hashMap->TOMBSTONE) { + firstTombstone = index; + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % hashMap->capacity; + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + return firstTombstone == -1 ? index : firstTombstone; + } + + /* 조회 연산 */ + char *get(HashMapOpenAddressing *hashMap, int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(hashMap, key); + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if (hashMap->buckets[index] != NULL && hashMap->buckets[index] != hashMap->TOMBSTONE) { + return hashMap->buckets[index]->val; + } + // 키-값 쌍이 없으면 빈 문자열을 반환한다 + return ""; + } + + /* 추가 연산 */ + void put(HashMapOpenAddressing *hashMap, int key, char *val) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (loadFactor(hashMap) > hashMap->loadThres) { + extend(hashMap); + } + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(hashMap, key); + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if (hashMap->buckets[index] != NULL && hashMap->buckets[index] != hashMap->TOMBSTONE) { + free(hashMap->buckets[index]->val); + hashMap->buckets[index]->val = (char *)malloc(sizeof(strlen(val) + 1)); + strcpy(hashMap->buckets[index]->val, val); + hashMap->buckets[index]->val[strlen(val)] = '\0'; + return; + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + Pair *pair = (Pair *)malloc(sizeof(Pair)); + pair->key = key; + pair->val = (char *)malloc(sizeof(strlen(val) + 1)); + strcpy(pair->val, val); + pair->val[strlen(val)] = '\0'; + + hashMap->buckets[index] = pair; + hashMap->size++; + } + + /* 삭제 연산 */ + void removeItem(HashMapOpenAddressing *hashMap, int key) { + // key에 해당하는 버킷 인덱스 탐색 + int index = findBucket(hashMap, key); + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if (hashMap->buckets[index] != NULL && hashMap->buckets[index] != hashMap->TOMBSTONE) { + Pair *pair = hashMap->buckets[index]; + free(pair->val); + free(pair); + hashMap->buckets[index] = hashMap->TOMBSTONE; + hashMap->size--; + } + } + + /* 해시 테이블 용량 확장 */ + void extend(HashMapOpenAddressing *hashMap) { + // 원래 해시 테이블을 임시로 저장한다 + Pair **bucketsTmp = hashMap->buckets; + int oldCapacity = hashMap->capacity; + // 용량을 확장한 새 해시 테이블을 초기화한다 + hashMap->capacity *= hashMap->extendRatio; + hashMap->buckets = (Pair **)calloc(hashMap->capacity, sizeof(Pair *)); + hashMap->size = 0; + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (int i = 0; i < oldCapacity; i++) { + Pair *pair = bucketsTmp[i]; + if (pair != NULL && pair != hashMap->TOMBSTONE) { + put(hashMap, pair->key, pair->val); + free(pair->val); + free(pair); + } + } + free(bucketsTmp); + } + + /* 해시 테이블 출력 */ + void print(HashMapOpenAddressing *hashMap) { + for (int i = 0; i < hashMap->capacity; i++) { + Pair *pair = hashMap->buckets[i]; + if (pair == NULL) { + printf("NULL\n"); + } else if (pair == hashMap->TOMBSTONE) { + printf("TOMBSTONE\n"); + } else { + printf("%d -> %s\n", pair->key, pair->val); + } + } + } + ``` + +=== "Kotlin" + + ```kotlin title="hash_map_open_addressing.kt" + /* 개방 주소법 해시 테이블 */ + class HashMapOpenAddressing { + private var size: Int // 키-값 쌍 개수 + private var capacity: Int // 해시 테이블 용량 + private val loadThres: Double // 용량 확장을 수행하는 적재율 임계값 + private val extendRatio: Int // 용량 확장 배수 + private var buckets: Array // 버킷 배열 + private val TOMBSTONE: Pair // 삭제 표시 + + /* 생성자 */ + init { + size = 0 + capacity = 4 + loadThres = 2.0 / 3.0 + extendRatio = 2 + buckets = arrayOfNulls(capacity) + TOMBSTONE = Pair(-1, "-1") + } + + /* 해시 함수 */ + fun hashFunc(key: Int): Int { + return key % capacity + } + + /* 적재율 */ + fun loadFactor(): Double { + return (size / capacity).toDouble() + } + + /* key에 해당하는 버킷 인덱스 탐색 */ + fun findBucket(key: Int): Int { + var index = hashFunc(key) + var firstTombstone = -1 + // 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while (buckets[index] != null) { + // key를 만나면 해당 버킷 인덱스를 반환한다 + if (buckets[index]?.key == key) { + // 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if (firstTombstone != -1) { + buckets[firstTombstone] = buckets[index] + buckets[index] = TOMBSTONE + return firstTombstone // 옮긴 후의 버킷 인덱스를 반환한다 + } + return index // 버킷 인덱스를 반환한다 + } + // 처음 만난 삭제 표시를 기록한다 + if (firstTombstone == -1 && buckets[index] == TOMBSTONE) { + firstTombstone = index + } + // 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % capacity + } + // key가 없으면 추가할 위치의 인덱스를 반환한다 + return if (firstTombstone == -1) index else firstTombstone + } + + /* 조회 연산 */ + fun get(key: Int): String? { + // key에 해당하는 버킷 인덱스 탐색 + val index = findBucket(key) + // 키-값 쌍을 찾으면 해당 val을 반환한다 + if (buckets[index] != null && buckets[index] != TOMBSTONE) { + return buckets[index]?._val + } + // 키-값 쌍이 없으면 null을 반환한다 + return null + } + + /* 추가 연산 */ + fun put(key: Int, _val: String) { + // 적재율이 임계값을 초과하면 용량을 확장한다 + if (loadFactor() > loadThres) { + extend() + } + // key에 해당하는 버킷 인덱스 탐색 + val index = findBucket(key) + // 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + if (buckets[index] != null && buckets[index] != TOMBSTONE) { + buckets[index]!!._val = _val + return + } + // 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + buckets[index] = Pair(key, _val) + size++ + } + + /* 삭제 연산 */ + fun remove(key: Int) { + // key에 해당하는 버킷 인덱스 탐색 + val index = findBucket(key) + // 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + if (buckets[index] != null && buckets[index] != TOMBSTONE) { + buckets[index] = TOMBSTONE + size-- + } + } + + /* 해시 테이블 용량 확장 */ + fun extend() { + // 원래 해시 테이블을 임시로 저장한다 + val bucketsTmp = buckets + // 용량을 확장한 새 해시 테이블을 초기화한다 + capacity *= extendRatio + buckets = arrayOfNulls(capacity) + size = 0 + // 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for (pair in bucketsTmp) { + if (pair != null && pair != TOMBSTONE) { + put(pair.key, pair._val) + } + } + } + + /* 해시 테이블 출력 */ + fun print() { + for (pair in buckets) { + if (pair == null) { + println("null") + } else if (pair == TOMBSTONE) { + println("TOMESTOME") + } else { + println("${pair.key} -> ${pair._val}") + } + } + } + } + ``` + +=== "Ruby" + + ```ruby title="hash_map_open_addressing.rb" + ### 개방 주소법 해시 테이블 ### + class HashMapOpenAddressing + TOMBSTONE = Pair.new(-1, '-1') # 삭제 표시 + + ### 생성자 ### + def initialize + @size = 0 # 키-값 쌍 개수 + @capacity = 4 # 해시 테이블 용량 + @load_thres = 2.0 / 3.0 # 용량 확장을 수행하는 적재율 임계값 + @extend_ratio = 2 # 용량 확장 배수 + @buckets = Array.new(@capacity) # 버킷 배열 + end + + ### 해시 함수 ### + def hash_func(key) + key % @capacity + end + + ### 적재율 ### + def load_factor + @size / @capacity + end + + ### key에 해당하는 버킷 인덱스 탐색 ### + def find_bucket(key) + index = hash_func(key) + first_tombstone = -1 + # 선형 탐사로 탐색하고, 빈 버킷을 만나면 반복문을 종료한다 + while !@buckets[index].nil? + # key를 만나면 해당 버킷 인덱스를 반환한다 + if @buckets[index].key == key + # 이전에 삭제 표시를 만났으면 키-값 쌍을 그 인덱스로 옮긴다 + if first_tombstone != -1 + @buckets[first_tombstone] = @buckets[index] + @buckets[index] = TOMBSTONE + return first_tombstone # 옮긴 후의 버킷 인덱스를 반환한다 + end + return index # 버킷 인덱스를 반환한다 + end + # 처음 만난 삭제 표시를 기록한다 + first_tombstone = index if first_tombstone == -1 && @buckets[index] == TOMBSTONE + # 버킷 인덱스를 계산하고, 끝을 넘어가면 처음으로 돌아온다 + index = (index + 1) % @capacity + end + # key가 없으면 추가할 위치의 인덱스를 반환한다 + first_tombstone == -1 ? index : first_tombstone + end + + ### 조회 연산 ### + def get(key) + # key에 해당하는 버킷 인덱스 탐색 + index = find_bucket(key) + # 키-값 쌍을 찾으면 해당 val을 반환한다 + return @buckets[index].val unless [nil, TOMBSTONE].include?(@buckets[index]) + # 키-값 쌍이 없으면 nil을 반환한다 + nil + end + + ### 추가 연산 ### + def put(key, val) + # 적재율이 임계값을 초과하면 용량을 확장한다 + extend if load_factor > @load_thres + # key에 해당하는 버킷 인덱스 탐색 + index = find_bucket(key) + # 키-값 쌍을 찾으면 val을 덮어쓰고 반환한다 + unless [nil, TOMBSTONE].include?(@buckets[index]) + @buckets[index].val = val + return + end + # 키-값 쌍이 없으면 해당 키-값 쌍을 추가한다 + @buckets[index] = Pair.new(key, val) + @size += 1 + end + + ### 삭제 연산 ### + def remove(key) + # key에 해당하는 버킷 인덱스 탐색 + index = find_bucket(key) + # 키-값 쌍을 찾으면 삭제 표시로 덮어쓴다 + unless [nil, TOMBSTONE].include?(@buckets[index]) + @buckets[index] = TOMBSTONE + @size -= 1 + end + end + + ### 해시 테이블 용량 확장 ### + def extend + # 원래 해시 테이블을 임시로 저장한다 + buckets_tmp = @buckets + # 용량을 확장한 새 해시 테이블을 초기화한다 + @capacity *= @extend_ratio + @buckets = Array.new(@capacity) + @size = 0 + # 원래 해시 테이블의 키-값 쌍을 새 해시 테이블로 옮긴다 + for pair in buckets_tmp + put(pair.key, pair.val) unless [nil, TOMBSTONE].include?(pair) + end + end + + ### 해시 테이블 출력 ### + def print + for pair in @buckets + if pair.nil? + puts "Nil" + elsif pair == TOMBSTONE + puts "TOMBSTONE" + else + puts "#{pair.key} -> #{pair.val}" + end + end + end + end + ``` + +### 2.   이차 탐사 + +이차 탐사는 선형 탐사처럼 개방 주소법의 대표적인 전략이다. 충돌이 발생하면 고정 간격으로 이동하는 대신 ‘탐사 횟수의 제곱’만큼, 즉 $1, 4, 9, \dots$칸을 이동한다. + +이차 탐사의 주요 장점은 다음과 같다. + +- 탐사 횟수의 제곱만큼 떨어진 위치로 이동하여 선형 탐사의 군집 현상을 완화하려 한다. +- 더 먼 거리로 건너뛰며 빈 위치를 찾으므로 데이터를 더 고르게 분포시키는 데 도움이 된다. + +하지만 이차 탐사도 완벽하지는 않다. + +- 일부 위치가 다른 위치보다 더 쉽게 채워지는 군집 현상이 여전히 존재한다. +- 제곱의 증가 때문에 해시 테이블 전체를 탐사하지 못할 수 있다. 즉, 빈 버킷이 있어도 그곳에 도달하지 못할 수 있다. + +### 3.   다중 해싱 + +이름 그대로 여러 해시 함수 $f_1(x)$, $f_2(x)$, $f_3(x)$, $\dots$를 사용하여 탐사하는 방법이다. + +- **원소 삽입**: 해시 함수 $f_1(x)$에서 충돌하면 $f_2(x)$를 시도한다. 이런 식으로 빈 위치를 찾을 때까지 계속한 뒤 원소를 넣는다. +- **원소 탐색**: 같은 해시 함수 순서로 탐색하여 목표 원소를 찾으면 반환한다. 빈 위치를 만나거나 모든 함수를 시도했다면 원소가 없다는 뜻이므로 `None`을 반환한다. + +선형 탐사보다 군집 현상이 덜 발생하지만, 여러 해시 함수로 인해 추가 계산이 필요하다. + +!!! tip "참고" + + 개방 주소법의 선형 탐사, 이차 탐사, 다중 해싱 모두 ‘원소를 곧바로 삭제할 수 없다’는 문제가 있다는 점에 주의하자. + +## 6.2.3   프로그래밍 언어의 선택 + +프로그래밍 언어마다 해시 테이블 구현 전략이 다르다. 몇 가지 예를 살펴보자. + +- Python은 개방 주소법을 사용한다. 딕셔너리 `dict`는 의사 난수를 이용해 탐사한다. +- Java는 체이닝을 사용한다. JDK 1.8부터는 `HashMap` 내부 배열 길이가 64 이상이고 연결 리스트 길이가 8 이상이면, 탐색 성능을 높이기 위해 연결 리스트를 레드-블랙 트리로 바꾼다. +- Go 1.24부터 내장 `map`은 기본적으로 Swiss Table 기반의 개방 주소법을 사용한다. Go 1.23 이하에서는 체이닝을 사용하며, 버킷 하나에 키-값 쌍을 최대 8개 저장하고 용량을 넘으면 오버플로 버킷을 연결한다. diff --git a/ko/docs/chapter_hashing/hash_map.md b/ko/docs/chapter_hashing/hash_map.md new file mode 100644 index 000000000..213c0b375 --- /dev/null +++ b/ko/docs/chapter_hashing/hash_map.md @@ -0,0 +1,1788 @@ +--- +comments: true +--- + +# 6.1   해시 테이블 + +해시 테이블(hash table)은 해시 표라고도 하며, 키 `key`와 값 `value` 사이의 대응 관계를 만들어 원소를 효율적으로 조회한다. 구체적으로 키 `key`를 입력하면 해당 값 `value`를 $O(1)$ 시간에 얻을 수 있다. + +아래 그림(6-1)과 같이 학생 $n$명에게 각각 ‘이름’과 ‘학번’이라는 두 데이터가 있다고 하자. ‘학번을 입력하면 해당 이름을 반환하는’ 조회 기능을 구현하려면 그림의 해시 테이블을 사용할 수 있다. + +![해시 테이블의 추상적 표현](hash_map.assets/hash_table_lookup.png){ class="animation-figure" } + +

그림 6-1   해시 테이블의 추상적 표현

+ +해시 테이블 외에 배열과 연결 리스트로도 조회 기능을 구현할 수 있다. 효율 비교는 아래 표(6-1)와 같다. + +- **원소 추가**: 배열(연결 리스트)의 끝에 원소를 추가하면 되므로 $O(1)$ 시간이 걸린다. +- **원소 조회**: 배열(연결 리스트)이 정렬되어 있지 않으므로 모든 원소를 순회해야 하며 $O(n)$ 시간이 걸린다. +- **원소 삭제**: 먼저 원소를 찾은 뒤 배열(연결 리스트)에서 삭제해야 하므로 $O(n)$ 시간이 걸린다. + +

표 6-1   원소 조회 효율 비교

+ +
+ +| | 배열 | 연결 리스트 | 해시 테이블 | +| -------- | ------ | ------ | ------ | +| 원소 탐색 | $O(n)$ | $O(n)$ | $O(1)$ | +| 원소 추가 | $O(1)$ | $O(1)$ | $O(1)$ | +| 원소 삭제 | $O(n)$ | $O(n)$ | $O(1)$ | + +
+ +이처럼 **해시 테이블에서는 삽입, 삭제, 탐색, 갱신의 시간 복잡도가 모두 $O(1)$**로 매우 효율적이다. + +## 6.1.1   해시 테이블의 기본 연산 + +대표적인 연산에는 초기화, 조회, 키-값 쌍 추가와 삭제 등이 있다. 예제 코드는 다음과 같다. + +=== "Python" + + ```python title="hash_map.py" + # 해시 테이블 초기화 + hmap: dict = {} + + # 추가 연산 + # 해시 테이블에 키-값 쌍 (key, value) 추가 + hmap[12836] = "小哈" + hmap[15937] = "小啰" + hmap[16750] = "小算" + hmap[13276] = "小法" + hmap[10583] = "小鸭" + + # 조회 연산 + # 해시 테이블에 키 key를 입력하여 값 value를 얻음 + name: str = hmap[15937] + + # 삭제 연산 + # 해시 테이블에서 키-값 쌍 (key, value) 삭제 + hmap.pop(10583) + ``` + +=== "C++" + + ```cpp title="hash_map.cpp" + /* 해시 테이블 초기화 */ + unordered_map map; + + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + map[12836] = "小哈"; + map[15937] = "小啰"; + map[16750] = "小算"; + map[13276] = "小法"; + map[10583] = "小鸭"; + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + string name = map[15937]; + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + map.erase(10583); + ``` + +=== "Java" + + ```java title="hash_map.java" + /* 해시 테이블 초기화 */ + Map map = new HashMap<>(); + + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + map.put(12836, "小哈"); + map.put(15937, "小啰"); + map.put(16750, "小算"); + map.put(13276, "小法"); + map.put(10583, "小鸭"); + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + String name = map.get(15937); + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + map.remove(10583); + ``` + +=== "C#" + + ```csharp title="hash_map.cs" + /* 해시 테이블 초기화 */ + Dictionary map = new() { + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + { 12836, "小哈" }, + { 15937, "小啰" }, + { 16750, "小算" }, + { 13276, "小法" }, + { 10583, "小鸭" } + }; + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + string name = map[15937]; + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + map.Remove(10583); + ``` + +=== "Go" + + ```go title="hash_map_test.go" + /* 해시 테이블 초기화 */ + hmap := make(map[int]string) + + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + hmap[12836] = "小哈" + hmap[15937] = "小啰" + hmap[16750] = "小算" + hmap[13276] = "小法" + hmap[10583] = "小鸭" + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + name := hmap[15937] + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + delete(hmap, 10583) + ``` + +=== "Swift" + + ```swift title="hash_map.swift" + /* 해시 테이블 초기화 */ + var map: [Int: String] = [:] + + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + map[12836] = "小哈" + map[15937] = "小啰" + map[16750] = "小算" + map[13276] = "小法" + map[10583] = "小鸭" + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + let name = map[15937]! + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + map.removeValue(forKey: 10583) + ``` + +=== "JS" + + ```javascript title="hash_map.js" + /* 해시 테이블 초기화 */ + const map = new Map(); + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + map.set(12836, '小哈'); + map.set(15937, '小啰'); + map.set(16750, '小算'); + map.set(13276, '小法'); + map.set(10583, '小鸭'); + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + let name = map.get(15937); + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + map.delete(10583); + ``` + +=== "TS" + + ```typescript title="hash_map.ts" + /* 해시 테이블 초기화 */ + const map = new Map(); + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + map.set(12836, '小哈'); + map.set(15937, '小啰'); + map.set(16750, '小算'); + map.set(13276, '小法'); + map.set(10583, '小鸭'); + console.info('\n추가한 뒤의 해시 테이블\nKey -> Value'); + console.info(map); + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + let name = map.get(15937); + console.info('\n학번 15937을 입력하여 찾은 이름 ' + name); + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + map.delete(10583); + console.info('\n10583을 삭제한 뒤의 해시 테이블\nKey -> Value'); + console.info(map); + ``` + +=== "Dart" + + ```dart title="hash_map.dart" + /* 해시 테이블 초기화 */ + Map map = {}; + + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + map[12836] = "小哈"; + map[15937] = "小啰"; + map[16750] = "小算"; + map[13276] = "小法"; + map[10583] = "小鸭"; + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + String name = map[15937]; + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + map.remove(10583); + ``` + +=== "Rust" + + ```rust title="hash_map.rs" + use std::collections::HashMap; + + /* 해시 테이블 초기화 */ + let mut map: HashMap = HashMap::new(); + + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + map.insert(12836, "小哈".to_string()); + map.insert(15937, "小啰".to_string()); + map.insert(16750, "小算".to_string()); + map.insert(13279, "小法".to_string()); + map.insert(10583, "小鸭".to_string()); + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + let _name: Option<&String> = map.get(&15937); + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + let _removed_value: Option = map.remove(&10583); + ``` + +=== "C" + + ```c title="hash_map.c" + // C는 내장 해시 테이블을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="hash_map.kt" + /* 해시 테이블 초기화 */ + val map = HashMap() + + /* 추가 연산 */ + // 해시 테이블에 키-값 쌍 (key, value) 추가 + map[12836] = "小哈" + map[15937] = "小啰" + map[16750] = "小算" + map[13276] = "小法" + map[10583] = "小鸭" + + /* 조회 연산 */ + // 해시 테이블에 키 key를 입력하여 값 value를 얻음 + val name = map[15937] + + /* 삭제 연산 */ + // 해시 테이블에서 키-값 쌍 (key, value) 삭제 + map.remove(10583) + ``` + +=== "Ruby" + + ```ruby title="hash_map.rb" + # 해시 테이블 초기화 + hmap = {} + + # 추가 연산 + # 해시 테이블에 키-값 쌍 (key, value) 추가 + hmap[12836] = "小哈" + hmap[15937] = "小啰" + hmap[16750] = "小算" + hmap[13276] = "小法" + hmap[10583] = "小鸭" + + # 조회 연산 + # 해시 테이블에 키 key를 입력하여 값 value를 얻음 + name = hmap[15937] + + # 삭제 연산 + # 해시 테이블에서 키-값 쌍 (key, value) 삭제 + hmap.delete(10583) + ``` + +??? pythontutor "코드 시각화" + +
+ + +해시 테이블을 순회하는 대표적인 방식은 키-값 쌍 순회, 키 순회, 값 순회의 세 가지다. 예제 코드는 다음과 같다. + +=== "Python" + + ```python title="hash_map.py" + # 해시 테이블 순회 + # 키-값 쌍 key->value 순회 + for key, value in hmap.items(): + print(key, "->", value) + # 키 key만 순회 + for key in hmap.keys(): + print(key) + # 값 value만 순회 + for value in hmap.values(): + print(value) + ``` + +=== "C++" + + ```cpp title="hash_map.cpp" + /* 해시 테이블 순회 */ + // 키-값 쌍 key->value 순회 + for (auto kv: map) { + cout << kv.first << " -> " << kv.second << endl; + } + // 이터레이터로 key->value 순회 + for (auto iter = map.begin(); iter != map.end(); iter++) { + cout << iter->first << "->" << iter->second << endl; + } + ``` + +=== "Java" + + ```java title="hash_map.java" + /* 해시 테이블 순회 */ + // 키-값 쌍 key->value 순회 + for (Map.Entry kv: map.entrySet()) { + System.out.println(kv.getKey() + " -> " + kv.getValue()); + } + // 키 key만 순회 + for (int key: map.keySet()) { + System.out.println(key); + } + // 값 value만 순회 + for (String val: map.values()) { + System.out.println(val); + } + ``` + +=== "C#" + + ```csharp title="hash_map.cs" + /* 해시 테이블 순회 */ + // 키-값 쌍 Key->Value 순회 + foreach (var kv in map) { + Console.WriteLine(kv.Key + " -> " + kv.Value); + } + // 키 key만 순회 + foreach (int key in map.Keys) { + Console.WriteLine(key); + } + // 값 value만 순회 + foreach (string val in map.Values) { + Console.WriteLine(val); + } + ``` + +=== "Go" + + ```go title="hash_map_test.go" + /* 해시 테이블 순회 */ + // 키-값 쌍 key->value 순회 + for key, value := range hmap { + fmt.Println(key, "->", value) + } + // 키 key만 순회 + for key := range hmap { + fmt.Println(key) + } + // 값 value만 순회 + for _, value := range hmap { + fmt.Println(value) + } + ``` + +=== "Swift" + + ```swift title="hash_map.swift" + /* 해시 테이블 순회 */ + // 키-값 쌍 Key->Value 순회 + for (key, value) in map { + print("\(key) -> \(value)") + } + // 키 Key만 순회 + for key in map.keys { + print(key) + } + // 값 Value만 순회 + for value in map.values { + print(value) + } + ``` + +=== "JS" + + ```javascript title="hash_map.js" + /* 해시 테이블 순회 */ + console.info('\n키-값 쌍 Key->Value 순회'); + for (const [k, v] of map.entries()) { + console.info(k + ' -> ' + v); + } + console.info('\n키 Key만 순회'); + for (const k of map.keys()) { + console.info(k); + } + console.info('\n값 Value만 순회'); + for (const v of map.values()) { + console.info(v); + } + ``` + +=== "TS" + + ```typescript title="hash_map.ts" + /* 해시 테이블 순회 */ + console.info('\n키-값 쌍 Key->Value 순회'); + for (const [k, v] of map.entries()) { + console.info(k + ' -> ' + v); + } + console.info('\n키 Key만 순회'); + for (const k of map.keys()) { + console.info(k); + } + console.info('\n값 Value만 순회'); + for (const v of map.values()) { + console.info(v); + } + ``` + +=== "Dart" + + ```dart title="hash_map.dart" + /* 해시 테이블 순회 */ + // 키-값 쌍 Key->Value 순회 + map.forEach((key, value) { + print('$key -> $value'); + }); + + // 키 Key만 순회 + map.keys.forEach((key) { + print(key); + }); + + // 값 Value만 순회 + map.values.forEach((value) { + print(value); + }); + ``` + +=== "Rust" + + ```rust title="hash_map.rs" + /* 해시 테이블 순회 */ + // 키-값 쌍 Key->Value 순회 + for (key, value) in &map { + println!("{key} -> {value}"); + } + + // 키 Key만 순회 + for key in map.keys() { + println!("{key}"); + } + + // 값 Value만 순회 + for value in map.values() { + println!("{value}"); + } + ``` + +=== "C" + + ```c title="hash_map.c" + // C는 내장 해시 테이블을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="hash_map.kt" + /* 해시 테이블 순회 */ + // 키-값 쌍 key->value 순회 + for ((key, value) in map) { + println("$key -> $value") + } + // 키 key만 순회 + for (key in map.keys) { + println(key) + } + // 값 value만 순회 + for (_val in map.values) { + println(_val) + } + ``` + +=== "Ruby" + + ```ruby title="hash_map.rb" + # 해시 테이블 순회 + # 키-값 쌍 key->value 순회 + hmap.entries.each { |key, value| puts "#{key} -> #{value}" } + + # 키 key만 순회 + hmap.keys.each { |key| puts key } + + # 값 value만 순회 + hmap.values.each { |val| puts val } + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 6.1.2   간단한 해시 테이블 구현 + +먼저 가장 간단한 경우로 **배열 하나만 사용하여 해시 테이블을 구현해 보자**. 해시 테이블에서는 배열의 각 자리를 버킷(bucket)이라고 하며, 버킷마다 키-값 쌍 하나를 저장할 수 있다. 따라서 조회 연산은 `key`에 해당하는 버킷을 찾고 그 안에서 `value`를 얻는 것이다. + +그렇다면 `key`로 해당 버킷을 어떻게 찾을까? 이를 위해 해시 함수(hash function)를 사용한다. 해시 함수는 큰 입력 공간을 작은 출력 공간에 매핑한다. 해시 테이블에서 입력 공간은 모든 `key`이고, 출력 공간은 모든 버킷(배열 인덱스)이다. 즉, `key`를 입력하면 **해시 함수를 통해 그 키-값 쌍이 배열에 저장되는 위치를 얻을 수 있다**. + +`key`를 입력했을 때 해시 함수의 계산 과정은 다음 두 단계로 나뉜다. + +1. 어떤 해시 알고리즘 `hash()`로 해시값을 계산한다. +2. 해시값을 버킷 수(배열 길이) `capacity`로 나눈 나머지를 구하여 `key`에 해당하는 버킷(배열 인덱스) `index`를 얻는다. + +```shell +index = hash(key) % capacity +``` + +그런 다음 `index`로 해시 테이블의 해당 버킷에 접근하여 `value`를 얻는다. + +배열 길이가 `capacity = 100`이고 해시 알고리즘이 `hash(key) = key`이면, 해시 함수는 `key % 100`이다. 아래 그림(6-2)은 학번을 `key`, 이름을 `value`로 삼아 해시 함수의 작동 원리를 보여 준다. + +![해시 함수의 작동 원리](hash_map.assets/hash_function.png){ class="animation-figure" } + +

그림 6-2   해시 함수의 작동 원리

+ +다음 코드는 간단한 해시 테이블을 구현한다. `key`와 `value`를 `Pair` 클래스로 묶어 키-값 쌍을 표현한다. + +=== "Python" + + ```python title="array_hash_map.py" + class Pair: + """키-값 쌍""" + + def __init__(self, key: int, val: str): + self.key = key + self.val = val + + class ArrayHashMap: + """배열로 구현한 해시 테이블""" + + def __init__(self): + """생성자""" + # 버킷 100개를 포함하는 배열을 초기화한다 + self.buckets: list[Pair | None] = [None] * 100 + + def hash_func(self, key: int) -> int: + """해시 함수""" + index = key % 100 + return index + + def get(self, key: int) -> str | None: + """조회 연산""" + index: int = self.hash_func(key) + pair: Pair = self.buckets[index] + if pair is None: + return None + return pair.val + + def put(self, key: int, val: str): + """추가 및 갱신 연산""" + pair = Pair(key, val) + index: int = self.hash_func(key) + self.buckets[index] = pair + + def remove(self, key: int): + """삭제 연산""" + index: int = self.hash_func(key) + # None으로 설정하여 삭제를 나타낸다 + self.buckets[index] = None + + def entry_set(self) -> list[Pair]: + """모든 키-값 쌍 반환""" + result: list[Pair] = [] + for pair in self.buckets: + if pair is not None: + result.append(pair) + return result + + def key_set(self) -> list[int]: + """모든 키 반환""" + result = [] + for pair in self.buckets: + if pair is not None: + result.append(pair.key) + return result + + def value_set(self) -> list[str]: + """모든 값 반환""" + result = [] + for pair in self.buckets: + if pair is not None: + result.append(pair.val) + return result + + def print(self): + """해시 테이블 출력""" + for pair in self.buckets: + if pair is not None: + print(pair.key, "->", pair.val) + ``` + +=== "C++" + + ```cpp title="array_hash_map.cpp" + /* 키-값 쌍 */ + struct Pair { + public: + int key; + string val; + Pair(int key, string val) { + this->key = key; + this->val = val; + } + }; + + /* 배열로 구현한 해시 테이블 */ + class ArrayHashMap { + private: + vector buckets; + + public: + ArrayHashMap() { + // 버킷 100개를 포함하는 배열을 초기화한다 + buckets = vector(100); + } + + ~ArrayHashMap() { + // 메모리 해제 + for (const auto &bucket : buckets) { + delete bucket; + } + buckets.clear(); + } + + /* 해시 함수 */ + int hashFunc(int key) { + int index = key % 100; + return index; + } + + /* 조회 연산 */ + string get(int key) { + int index = hashFunc(key); + Pair *pair = buckets[index]; + if (pair == nullptr) + return ""; + return pair->val; + } + + /* 추가 연산 */ + void put(int key, string val) { + Pair *pair = new Pair(key, val); + int index = hashFunc(key); + buckets[index] = pair; + } + + /* 삭제 연산 */ + void remove(int key) { + int index = hashFunc(key); + // 메모리를 해제하고 nullptr로 설정한다 + delete buckets[index]; + buckets[index] = nullptr; + } + + /* 모든 키-값 쌍 반환 */ + vector pairSet() { + vector pairSet; + for (Pair *pair : buckets) { + if (pair != nullptr) { + pairSet.push_back(pair); + } + } + return pairSet; + } + + /* 모든 키 반환 */ + vector keySet() { + vector keySet; + for (Pair *pair : buckets) { + if (pair != nullptr) { + keySet.push_back(pair->key); + } + } + return keySet; + } + + /* 모든 값 반환 */ + vector valueSet() { + vector valueSet; + for (Pair *pair : buckets) { + if (pair != nullptr) { + valueSet.push_back(pair->val); + } + } + return valueSet; + } + + /* 해시 테이블 출력 */ + void print() { + for (Pair *kv : pairSet()) { + cout << kv->key << " -> " << kv->val << endl; + } + } + }; + ``` + +=== "Java" + + ```java title="array_hash_map.java" + /* 키-값 쌍 */ + class Pair { + public int key; + public String val; + + public Pair(int key, String val) { + this.key = key; + this.val = val; + } + } + + /* 배열로 구현한 해시 테이블 */ + class ArrayHashMap { + private List buckets; + + public ArrayHashMap() { + // 버킷 100개를 포함하는 배열을 초기화한다 + buckets = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + buckets.add(null); + } + } + + /* 해시 함수 */ + private int hashFunc(int key) { + int index = key % 100; + return index; + } + + /* 조회 연산 */ + public String get(int key) { + int index = hashFunc(key); + Pair pair = buckets.get(index); + if (pair == null) + return null; + return pair.val; + } + + /* 추가 연산 */ + public void put(int key, String val) { + Pair pair = new Pair(key, val); + int index = hashFunc(key); + buckets.set(index, pair); + } + + /* 삭제 연산 */ + public void remove(int key) { + int index = hashFunc(key); + // null로 설정하여 삭제를 나타낸다 + buckets.set(index, null); + } + + /* 모든 키-값 쌍 반환 */ + public List pairSet() { + List pairSet = new ArrayList<>(); + for (Pair pair : buckets) { + if (pair != null) + pairSet.add(pair); + } + return pairSet; + } + + /* 모든 키 반환 */ + public List keySet() { + List keySet = new ArrayList<>(); + for (Pair pair : buckets) { + if (pair != null) + keySet.add(pair.key); + } + return keySet; + } + + /* 모든 값 반환 */ + public List valueSet() { + List valueSet = new ArrayList<>(); + for (Pair pair : buckets) { + if (pair != null) + valueSet.add(pair.val); + } + return valueSet; + } + + /* 해시 테이블 출력 */ + public void print() { + for (Pair kv : pairSet()) { + System.out.println(kv.key + " -> " + kv.val); + } + } + } + ``` + +=== "C#" + + ```csharp title="array_hash_map.cs" + /* 키-값 쌍 int->string */ + class Pair(int key, string val) { + public int key = key; + public string val = val; + } + + /* 배열로 구현한 해시 테이블 */ + class ArrayHashMap { + List buckets; + public ArrayHashMap() { + // 버킷 100개를 포함하는 배열을 초기화한다 + buckets = []; + for (int i = 0; i < 100; i++) { + buckets.Add(null); + } + } + + /* 해시 함수 */ + int HashFunc(int key) { + int index = key % 100; + return index; + } + + /* 조회 연산 */ + public string? Get(int key) { + int index = HashFunc(key); + Pair? pair = buckets[index]; + if (pair == null) return null; + return pair.val; + } + + /* 추가 연산 */ + public void Put(int key, string val) { + Pair pair = new(key, val); + int index = HashFunc(key); + buckets[index] = pair; + } + + /* 삭제 연산 */ + public void Remove(int key) { + int index = HashFunc(key); + // null로 설정하여 삭제를 나타낸다 + buckets[index] = null; + } + + /* 모든 키-값 쌍 반환 */ + public List PairSet() { + List pairSet = []; + foreach (Pair? pair in buckets) { + if (pair != null) + pairSet.Add(pair); + } + return pairSet; + } + + /* 모든 키 반환 */ + public List KeySet() { + List keySet = []; + foreach (Pair? pair in buckets) { + if (pair != null) + keySet.Add(pair.key); + } + return keySet; + } + + /* 모든 값 반환 */ + public List ValueSet() { + List valueSet = []; + foreach (Pair? pair in buckets) { + if (pair != null) + valueSet.Add(pair.val); + } + return valueSet; + } + + /* 해시 테이블 출력 */ + public void Print() { + foreach (Pair kv in PairSet()) { + Console.WriteLine(kv.key + " -> " + kv.val); + } + } + } + ``` + +=== "Go" + + ```go title="array_hash_map.go" + /* 키-값 쌍 */ + type pair struct { + key int + val string + } + + /* 배열로 구현한 해시 테이블 */ + type arrayHashMap struct { + buckets []*pair + } + + /* 해시 테이블 초기화 */ + func newArrayHashMap() *arrayHashMap { + // 버킷 100개를 포함하는 배열을 초기화한다 + buckets := make([]*pair, 100) + return &arrayHashMap{buckets: buckets} + } + + /* 해시 함수 */ + func (a *arrayHashMap) hashFunc(key int) int { + index := key % 100 + return index + } + + /* 조회 연산 */ + func (a *arrayHashMap) get(key int) string { + index := a.hashFunc(key) + pair := a.buckets[index] + if pair == nil { + return "찾을 수 없음" + } + return pair.val + } + + /* 추가 연산 */ + func (a *arrayHashMap) put(key int, val string) { + pair := &pair{key: key, val: val} + index := a.hashFunc(key) + a.buckets[index] = pair + } + + /* 삭제 연산 */ + func (a *arrayHashMap) remove(key int) { + index := a.hashFunc(key) + // nil로 설정하여 삭제를 나타낸다 + a.buckets[index] = nil + } + + /* 모든 키-값 쌍 반환 */ + func (a *arrayHashMap) pairSet() []*pair { + var pairs []*pair + for _, pair := range a.buckets { + if pair != nil { + pairs = append(pairs, pair) + } + } + return pairs + } + + /* 모든 키 반환 */ + func (a *arrayHashMap) keySet() []int { + var keys []int + for _, pair := range a.buckets { + if pair != nil { + keys = append(keys, pair.key) + } + } + return keys + } + + /* 모든 값 반환 */ + func (a *arrayHashMap) valueSet() []string { + var values []string + for _, pair := range a.buckets { + if pair != nil { + values = append(values, pair.val) + } + } + return values + } + + /* 해시 테이블 출력 */ + func (a *arrayHashMap) print() { + for _, pair := range a.buckets { + if pair != nil { + fmt.Println(pair.key, "->", pair.val) + } + } + } + ``` + +=== "Swift" + + ```swift title="array_hash_map.swift" + /* 키-값 쌍 */ + class Pair: Equatable { + public var key: Int + public var val: String + + public init(key: Int, val: String) { + self.key = key + self.val = val + } + + public static func == (lhs: Pair, rhs: Pair) -> Bool { + lhs.key == rhs.key && lhs.val == rhs.val + } + } + + /* 배열로 구현한 해시 테이블 */ + class ArrayHashMap { + private var buckets: [Pair?] + + init() { + // 버킷 100개를 포함하는 배열을 초기화한다 + buckets = Array(repeating: nil, count: 100) + } + + /* 해시 함수 */ + private func hashFunc(key: Int) -> Int { + let index = key % 100 + return index + } + + /* 조회 연산 */ + func get(key: Int) -> String? { + let index = hashFunc(key: key) + let pair = buckets[index] + return pair?.val + } + + /* 추가 연산 */ + func put(key: Int, val: String) { + let pair = Pair(key: key, val: val) + let index = hashFunc(key: key) + buckets[index] = pair + } + + /* 삭제 연산 */ + func remove(key: Int) { + let index = hashFunc(key: key) + // nil로 설정하여 삭제를 나타낸다 + buckets[index] = nil + } + + /* 모든 키-값 쌍 반환 */ + func pairSet() -> [Pair] { + buckets.compactMap { $0 } + } + + /* 모든 키 반환 */ + func keySet() -> [Int] { + buckets.compactMap { $0?.key } + } + + /* 모든 값 반환 */ + func valueSet() -> [String] { + buckets.compactMap { $0?.val } + } + + /* 해시 테이블 출력 */ + func print() { + for pair in pairSet() { + Swift.print("\(pair.key) -> \(pair.val)") + } + } + } + ``` + +=== "JS" + + ```javascript title="array_hash_map.js" + /* 키-값 쌍 Number -> String */ + class Pair { + constructor(key, val) { + this.key = key; + this.val = val; + } + } + + /* 배열로 구현한 해시 테이블 */ + class ArrayHashMap { + #buckets; + constructor() { + // 버킷 100개를 포함하는 배열을 초기화한다 + this.#buckets = new Array(100).fill(null); + } + + /* 해시 함수 */ + #hashFunc(key) { + return key % 100; + } + + /* 조회 연산 */ + get(key) { + let index = this.#hashFunc(key); + let pair = this.#buckets[index]; + if (pair === null) return null; + return pair.val; + } + + /* 추가 연산 */ + set(key, val) { + let index = this.#hashFunc(key); + this.#buckets[index] = new Pair(key, val); + } + + /* 삭제 연산 */ + delete(key) { + let index = this.#hashFunc(key); + // null로 설정하여 삭제를 나타낸다 + this.#buckets[index] = null; + } + + /* 모든 키-값 쌍 반환 */ + entries() { + let arr = []; + for (let i = 0; i < this.#buckets.length; i++) { + if (this.#buckets[i]) { + arr.push(this.#buckets[i]); + } + } + return arr; + } + + /* 모든 키 반환 */ + keys() { + let arr = []; + for (let i = 0; i < this.#buckets.length; i++) { + if (this.#buckets[i]) { + arr.push(this.#buckets[i].key); + } + } + return arr; + } + + /* 모든 값 반환 */ + values() { + let arr = []; + for (let i = 0; i < this.#buckets.length; i++) { + if (this.#buckets[i]) { + arr.push(this.#buckets[i].val); + } + } + return arr; + } + + /* 해시 테이블 출력 */ + print() { + let pairSet = this.entries(); + for (const pair of pairSet) { + console.info(`${pair.key} -> ${pair.val}`); + } + } + } + ``` + +=== "TS" + + ```typescript title="array_hash_map.ts" + /* 키-값 쌍 Number -> String */ + class Pair { + public key: number; + public val: string; + + constructor(key: number, val: string) { + this.key = key; + this.val = val; + } + } + + /* 배열로 구현한 해시 테이블 */ + class ArrayHashMap { + private readonly buckets: (Pair | null)[]; + + constructor() { + // 버킷 100개를 포함하는 배열을 초기화한다 + this.buckets = new Array(100).fill(null); + } + + /* 해시 함수 */ + private hashFunc(key: number): number { + return key % 100; + } + + /* 조회 연산 */ + public get(key: number): string | null { + let index = this.hashFunc(key); + let pair = this.buckets[index]; + if (pair === null) return null; + return pair.val; + } + + /* 추가 연산 */ + public set(key: number, val: string) { + let index = this.hashFunc(key); + this.buckets[index] = new Pair(key, val); + } + + /* 삭제 연산 */ + public delete(key: number) { + let index = this.hashFunc(key); + // null로 설정하여 삭제를 나타낸다 + this.buckets[index] = null; + } + + /* 모든 키-값 쌍 반환 */ + public entries(): (Pair | null)[] { + let arr: (Pair | null)[] = []; + for (let i = 0; i < this.buckets.length; i++) { + if (this.buckets[i]) { + arr.push(this.buckets[i]); + } + } + return arr; + } + + /* 모든 키 반환 */ + public keys(): (number | undefined)[] { + let arr: (number | undefined)[] = []; + for (let i = 0; i < this.buckets.length; i++) { + if (this.buckets[i]) { + arr.push(this.buckets[i].key); + } + } + return arr; + } + + /* 모든 값 반환 */ + public values(): (string | undefined)[] { + let arr: (string | undefined)[] = []; + for (let i = 0; i < this.buckets.length; i++) { + if (this.buckets[i]) { + arr.push(this.buckets[i].val); + } + } + return arr; + } + + /* 해시 테이블 출력 */ + public print() { + let pairSet = this.entries(); + for (const pair of pairSet) { + console.info(`${pair.key} -> ${pair.val}`); + } + } + } + ``` + +=== "Dart" + + ```dart title="array_hash_map.dart" + /* 키-값 쌍 */ + class Pair { + int key; + String val; + Pair(this.key, this.val); + } + + /* 배열로 구현한 해시 테이블 */ + class ArrayHashMap { + late List _buckets; + + ArrayHashMap() { + // 버킷 100개를 포함하는 배열을 초기화한다 + _buckets = List.filled(100, null); + } + + /* 해시 함수 */ + int _hashFunc(int key) { + final int index = key % 100; + return index; + } + + /* 조회 연산 */ + String? get(int key) { + final int index = _hashFunc(key); + final Pair? pair = _buckets[index]; + if (pair == null) { + return null; + } + return pair.val; + } + + /* 추가 연산 */ + void put(int key, String val) { + final Pair pair = Pair(key, val); + final int index = _hashFunc(key); + _buckets[index] = pair; + } + + /* 삭제 연산 */ + void remove(int key) { + final int index = _hashFunc(key); + _buckets[index] = null; + } + + /* 모든 키-값 쌍 반환 */ + List pairSet() { + List pairSet = []; + for (final Pair? pair in _buckets) { + if (pair != null) { + pairSet.add(pair); + } + } + return pairSet; + } + + /* 모든 키 반환 */ + List keySet() { + List keySet = []; + for (final Pair? pair in _buckets) { + if (pair != null) { + keySet.add(pair.key); + } + } + return keySet; + } + + /* 모든 값 반환 */ + List values() { + List valueSet = []; + for (final Pair? pair in _buckets) { + if (pair != null) { + valueSet.add(pair.val); + } + } + return valueSet; + } + + /* 해시 테이블 출력 */ + void printHashMap() { + for (final Pair kv in pairSet()) { + print("${kv.key} -> ${kv.val}"); + } + } + } + ``` + +=== "Rust" + + ```rust title="array_hash_map.rs" + /* 키-값 쌍 */ + #[derive(Debug, Clone, PartialEq)] + pub struct Pair { + pub key: i32, + pub val: String, + } + + /* 배열로 구현한 해시 테이블 */ + pub struct ArrayHashMap { + buckets: Vec>, + } + + impl ArrayHashMap { + pub fn new() -> ArrayHashMap { + // 버킷 100개를 포함하는 배열을 초기화한다 + Self { + buckets: vec![None; 100], + } + } + + /* 해시 함수 */ + fn hash_func(&self, key: i32) -> usize { + key as usize % 100 + } + + /* 조회 연산 */ + pub fn get(&self, key: i32) -> Option<&String> { + let index = self.hash_func(key); + self.buckets[index].as_ref().map(|pair| &pair.val) + } + + /* 추가 연산 */ + pub fn put(&mut self, key: i32, val: &str) { + let index = self.hash_func(key); + self.buckets[index] = Some(Pair { + key, + val: val.to_string(), + }); + } + + /* 삭제 연산 */ + pub fn remove(&mut self, key: i32) { + let index = self.hash_func(key); + // None으로 설정하여 삭제를 나타낸다 + self.buckets[index] = None; + } + + /* 모든 키-값 쌍 반환 */ + pub fn entry_set(&self) -> Vec<&Pair> { + self.buckets + .iter() + .filter_map(|pair| pair.as_ref()) + .collect() + } + + /* 모든 키 반환 */ + pub fn key_set(&self) -> Vec<&i32> { + self.buckets + .iter() + .filter_map(|pair| pair.as_ref().map(|pair| &pair.key)) + .collect() + } + + /* 모든 값 반환 */ + pub fn value_set(&self) -> Vec<&String> { + self.buckets + .iter() + .filter_map(|pair| pair.as_ref().map(|pair| &pair.val)) + .collect() + } + + /* 해시 테이블 출력 */ + pub fn print(&self) { + for pair in self.entry_set() { + println!("{} -> {}", pair.key, pair.val); + } + } + } + ``` + +=== "C" + + ```c title="array_hash_map.c" + /* 키-값 쌍 int->string */ + typedef struct { + int key; + char *val; + } Pair; + + /* 배열로 구현한 해시 테이블 */ + typedef struct { + Pair *buckets[MAX_SIZE]; + } ArrayHashMap; + + /* 생성자 */ + ArrayHashMap *newArrayHashMap() { + ArrayHashMap *hmap = malloc(sizeof(ArrayHashMap)); + for (int i=0; i < MAX_SIZE; i++) { + hmap->buckets[i] = NULL; + } + return hmap; + } + + /* 소멸자 */ + void delArrayHashMap(ArrayHashMap *hmap) { + for (int i = 0; i < MAX_SIZE; i++) { + if (hmap->buckets[i] != NULL) { + free(hmap->buckets[i]->val); + free(hmap->buckets[i]); + } + } + free(hmap); + } + + /* 추가 연산 */ + void put(ArrayHashMap *hmap, const int key, const char *val) { + Pair *Pair = malloc(sizeof(Pair)); + Pair->key = key; + Pair->val = malloc(strlen(val) + 1); + strcpy(Pair->val, val); + + int index = hashFunc(key); + hmap->buckets[index] = Pair; + } + + /* 삭제 연산 */ + void removeItem(ArrayHashMap *hmap, const int key) { + int index = hashFunc(key); + free(hmap->buckets[index]->val); + free(hmap->buckets[index]); + hmap->buckets[index] = NULL; + } + + /* 모든 키-값 쌍 반환 */ + void pairSet(ArrayHashMap *hmap, MapSet *set) { + Pair *entries; + int i = 0, index = 0; + int total = 0; + /* 유효한 키-값 쌍의 개수를 센다 */ + for (i = 0; i < MAX_SIZE; i++) { + if (hmap->buckets[i] != NULL) { + total++; + } + } + entries = malloc(sizeof(Pair) * total); + for (i = 0; i < MAX_SIZE; i++) { + if (hmap->buckets[i] != NULL) { + entries[index].key = hmap->buckets[i]->key; + entries[index].val = malloc(strlen(hmap->buckets[i]->val) + 1); + strcpy(entries[index].val, hmap->buckets[i]->val); + index++; + } + } + set->set = entries; + set->len = total; + } + + /* 모든 키 반환 */ + void keySet(ArrayHashMap *hmap, MapSet *set) { + int *keys; + int i = 0, index = 0; + int total = 0; + /* 유효한 키-값 쌍의 개수를 센다 */ + for (i = 0; i < MAX_SIZE; i++) { + if (hmap->buckets[i] != NULL) { + total++; + } + } + keys = malloc(total * sizeof(int)); + for (i = 0; i < MAX_SIZE; i++) { + if (hmap->buckets[i] != NULL) { + keys[index] = hmap->buckets[i]->key; + index++; + } + } + set->set = keys; + set->len = total; + } + + /* 모든 값 반환 */ + void valueSet(ArrayHashMap *hmap, MapSet *set) { + char **vals; + int i = 0, index = 0; + int total = 0; + /* 유효한 키-값 쌍의 개수를 센다 */ + for (i = 0; i < MAX_SIZE; i++) { + if (hmap->buckets[i] != NULL) { + total++; + } + } + vals = malloc(total * sizeof(char *)); + for (i = 0; i < MAX_SIZE; i++) { + if (hmap->buckets[i] != NULL) { + vals[index] = hmap->buckets[i]->val; + index++; + } + } + set->set = vals; + set->len = total; + } + + /* 해시 테이블 출력 */ + void print(ArrayHashMap *hmap) { + int i; + MapSet set; + pairSet(hmap, &set); + Pair *entries = (Pair *)set.set; + for (i = 0; i < set.len; i++) { + printf("%d -> %s\n", entries[i].key, entries[i].val); + } + free(set.set); + } + ``` + +=== "Kotlin" + + ```kotlin title="array_hash_map.kt" + /* 키-값 쌍 */ + class Pair( + var key: Int, + var _val: String + ) + + /* 배열로 구현한 해시 테이블 */ + class ArrayHashMap { + // 버킷 100개를 포함하는 배열을 초기화한다 + private val buckets = arrayOfNulls(100) + + /* 해시 함수 */ + fun hashFunc(key: Int): Int { + val index = key % 100 + return index + } + + /* 조회 연산 */ + fun get(key: Int): String? { + val index = hashFunc(key) + val pair = buckets[index] ?: return null + return pair._val + } + + /* 추가 연산 */ + fun put(key: Int, _val: String) { + val pair = Pair(key, _val) + val index = hashFunc(key) + buckets[index] = pair + } + + /* 삭제 연산 */ + fun remove(key: Int) { + val index = hashFunc(key) + // null로 설정하여 삭제를 나타낸다 + buckets[index] = null + } + + /* 모든 키-값 쌍 반환 */ + fun pairSet(): MutableList { + val pairSet = mutableListOf() + for (pair in buckets) { + if (pair != null) + pairSet.add(pair) + } + return pairSet + } + + /* 모든 키 반환 */ + fun keySet(): MutableList { + val keySet = mutableListOf() + for (pair in buckets) { + if (pair != null) + keySet.add(pair.key) + } + return keySet + } + + /* 모든 값 반환 */ + fun valueSet(): MutableList { + val valueSet = mutableListOf() + for (pair in buckets) { + if (pair != null) + valueSet.add(pair._val) + } + return valueSet + } + + /* 해시 테이블 출력 */ + fun print() { + for (kv in pairSet()) { + val key = kv.key + val _val = kv._val + println("$key -> $_val") + } + } + } + ``` + +=== "Ruby" + + ```ruby title="array_hash_map.rb" + ### 키-값 쌍 ### + class Pair + attr_accessor :key, :val + + def initialize(key, val) + @key = key + @val = val + end + end + + ### 배열로 구현한 해시 테이블 ### + class ArrayHashMap + ### 생성자 ### + def initialize + # 버킷 100개를 포함하는 배열을 초기화한다 + @buckets = Array.new(100) + end + + ### 해시 함수 ### + def hash_func(key) + index = key % 100 + end + + ### 조회 연산 ### + def get(key) + index = hash_func(key) + pair = @buckets[index] + + return if pair.nil? + pair.val + end + + ### 추가 연산 ### + def put(key, val) + pair = Pair.new(key, val) + index = hash_func(key) + @buckets[index] = pair + end + + ### 삭제 연산 ### + def remove(key) + index = hash_func(key) + # nil로 설정하여 삭제를 나타낸다 + @buckets[index] = nil + end + + ### 모든 키-값 쌍 반환 ### + def entry_set + result = [] + @buckets.each { |pair| result << pair unless pair.nil? } + result + end + + ### 모든 키 반환 ### + def key_set + result = [] + @buckets.each { |pair| result << pair.key unless pair.nil? } + result + end + + ### 모든 값 반환 ### + def value_set + result = [] + @buckets.each { |pair| result << pair.val unless pair.nil? } + result + end + + ### 해시 테이블 출력 ### + def print + @buckets.each { |pair| puts "#{pair.key} -> #{pair.val}" unless pair.nil? } + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 6.1.3   해시 충돌과 용량 확장 + +해시 함수는 본질적으로 모든 `key`로 이루어진 입력 공간을 배열의 모든 인덱스로 이루어진 출력 공간에 매핑한다. 입력 공간은 대개 출력 공간보다 훨씬 크므로 **이론적으로 ‘여러 입력이 같은 출력에 대응하는’ 경우가 반드시 존재한다**. + +앞의 해시 함수에서는 입력 `key`의 마지막 두 자리가 같으면 출력도 같다. 예를 들어 학번이 12836과 20336인 두 학생을 조회하면 다음과 같다. + +```shell +12836 % 100 = 36 +20336 % 100 = 36 +``` + +아래 그림(6-3)에서 두 학번이 같은 이름을 가리키는데, 이는 명백히 잘못되었다. 이처럼 여러 입력이 같은 출력에 대응하는 현상을 해시 충돌(hash collision)이라고 한다. + +![해시 충돌의 예](hash_map.assets/hash_collision.png){ class="animation-figure" } + +

그림 6-3   해시 충돌의 예

+ +해시 테이블의 용량 $n$이 커질수록 여러 `key`가 같은 버킷에 들어갈 확률은 낮아지고 충돌도 줄어든다. 따라서 **해시 테이블의 용량을 늘려 해시 충돌을 줄일 수 있다**. + +아래 그림(6-4)에서 확장 전에는 키-값 쌍 `(136, A)`와 `(236, D)`가 충돌하지만 확장 후에는 충돌이 사라진다. + +![해시 테이블의 용량 확장](hash_map.assets/hash_table_reshash.png){ class="animation-figure" } + +

그림 6-4   해시 테이블의 용량 확장

+ +배열 확장과 마찬가지로 해시 테이블을 확장하려면 모든 키-값 쌍을 기존 테이블에서 새 테이블로 옮겨야 하므로 시간이 많이 든다. 또한 용량 `capacity`가 바뀌므로 해시 함수로 모든 키-값 쌍의 저장 위치를 다시 계산해야 하며, 이는 확장 비용을 더욱 늘린다. 따라서 프로그래밍 언어는 보통 충분히 큰 용량을 미리 확보하여 잦은 확장을 방지한다. + +적재율(load factor)은 해시 테이블의 원소 수를 버킷 수로 나눈 값으로, 해시 충돌의 심각도를 측정하는 중요한 개념이다. **용량 확장을 시작하는 조건으로도 자주 사용된다**. 예를 들어 Java에서는 적재율이 $0.75$를 넘으면 해시 테이블의 용량을 기존의 $2$배로 늘린다. diff --git a/ko/docs/chapter_hashing/index.md b/ko/docs/chapter_hashing/index.md new file mode 100644 index 000000000..50a486bdb --- /dev/null +++ b/ko/docs/chapter_hashing/index.md @@ -0,0 +1,22 @@ +--- +comments: true +icon: material/table-search +--- + +# 제 6 장   해시 테이블 + +![해시 테이블](../assets/covers/chapter_hashing.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 컴퓨터 세계에서 해시 테이블은 영리한 사서와 같다. + + 청구 기호를 계산하는 방법을 알기에 원하는 책을 빠르게 찾아낸다. + +## 이 장의 내용 + +- [6.1   해시 테이블](hash_map.md) +- [6.2   해시 충돌](hash_collision.md) +- [6.3   해시 알고리즘](hash_algorithm.md) +- [6.4   요약](summary.md) +- [6.5   연습 문제](exercises.md) diff --git a/ko/docs/chapter_hashing/summary.md b/ko/docs/chapter_hashing/summary.md new file mode 100644 index 000000000..58f3ecec9 --- /dev/null +++ b/ko/docs/chapter_hashing/summary.md @@ -0,0 +1,55 @@ +--- +comments: true +--- + +# 6.4   요약 + +### 1.   핵심 정리 + +- 해시 테이블은 `key`를 입력받아 $O(1)$ 시간에 `value`를 조회하므로 매우 효율적이다. +- 대표적인 연산에는 조회, 키-값 쌍 추가와 삭제, 해시 테이블 순회 등이 있다. +- 해시 함수는 `key`를 배열 인덱스로 매핑하여 해당 버킷에 접근하고 `value`를 얻도록 한다. +- 서로 다른 두 `key`가 해시 함수를 거쳐 같은 배열 인덱스를 얻으면 잘못된 조회 결과가 발생할 수 있다. 이를 해시 충돌이라고 한다. +- 해시 테이블 용량이 클수록 충돌 확률이 낮으므로 용량 확장으로 충돌을 완화할 수 있다. 배열과 마찬가지로 해시 테이블의 용량 확장 비용도 크다. +- 적재율은 원소 수를 버킷 수로 나눈 값이며, 해시 충돌의 심각도를 반영한다. 용량 확장의 시작 조건으로 자주 사용된다. +- 체이닝은 단일 원소를 연결 리스트로 바꾸어 충돌하는 모든 원소를 같은 리스트에 저장한다. 리스트가 너무 길면 조회 효율이 낮아지므로, 레드-블랙 트리로 바꾸어 효율을 높일 수 있다. +- 개방 주소법은 여러 차례 탐사하여 해시 충돌을 처리한다. 선형 탐사는 고정 간격을 사용하며, 원소를 삭제할 수 없고 군집이 생기기 쉽다는 단점이 있다. 다중 해싱은 여러 해시 함수로 탐사하므로 군집은 덜 생기지만 계산량이 늘어난다. +- 프로그래밍 언어마다 해시 테이블 구현이 다르다. 예를 들어 Java의 `HashMap`은 체이닝을, Python의 `dict`는 개방 주소법을 사용한다. +- 해시 테이블의 해시 알고리즘에는 결정성, 높은 효율, 균등 분포가 필요하다. 암호학에서는 충돌 저항성과 눈사태 효과도 갖추어야 한다. +- 해시 알고리즘은 해시값의 균등 분포를 최대한 보장하고 충돌을 줄이기 위해 보통 큰 소수를 모듈러 연산의 법으로 사용한다. +- 대표적인 해시 알고리즘으로 MD5, SHA-1, SHA-2, SHA-3 등이 있다. MD5는 파일 무결성 검사에, SHA-2는 보안 응용과 프로토콜에 자주 쓰인다. +- 프로그래밍 언어는 보통 자료형별 내장 해시 알고리즘을 제공하여 버킷 인덱스를 계산한다. 일반적으로 불변 객체만 해시 가능하다. + +### 2.   Q & A + +**Q**: 해시 테이블의 시간 복잡도가 $O(n)$이 되는 경우는 언제인가? + +해시 충돌이 심하면 시간 복잡도가 $O(n)$으로 나빠진다. 해시 함수를 잘 설계하고 용량을 적절히 정하며 충돌이 비교적 고르게 분포하면 $O(1)$이다. 프로그래밍 언어의 내장 해시 테이블을 사용할 때는 보통 시간 복잡도를 $O(1)$로 본다. + +**Q**: 해시 함수 $f(x) = x$를 사용하면 충돌이 없을 텐데, 왜 그렇게 하지 않는가? + +$f(x) = x$에서는 각 원소에 고유한 버킷 인덱스가 대응하므로 배열과 같다. 그러나 입력 공간은 보통 출력 공간(배열 길이)보다 훨씬 크므로, 해시 함수의 마지막 단계에서 대개 배열 길이로 나눈 나머지를 구한다. 즉, 해시 테이블의 목적은 큰 상태 공간을 작은 공간에 매핑하면서 $O(1)$의 조회 효율을 제공하는 것이다. + +**Q**: 해시 테이블도 배열, 연결 리스트, 이진 트리로 구현되는데 어떻게 이들보다 효율이 높을 수 있는가? + +첫째, 시간 효율은 높아지지만 공간 효율은 낮아진다. 해시 테이블에는 사용하지 않는 메모리가 상당 부분 있다. + +둘째, 특정 활용 상황에서만 시간 효율이 높아진다. 같은 기능을 배열이나 연결 리스트로 같은 시간 복잡도에 구현할 수 있다면 보통 그쪽이 더 빠르다. 해시 함수 계산에 비용이 들어 시간 복잡도의 상수항이 더 크기 때문이다. + +마지막으로 해시 테이블의 시간 복잡도도 나빠질 수 있다. 예를 들어 체이닝에서는 연결 리스트나 레드-블랙 트리에서 탐색하므로, 여전히 $O(n)$ 시간으로 나빠질 위험이 있다. + +**Q**: 다중 해싱도 원소를 곧바로 삭제할 수 없는가? 삭제 표시를 한 공간은 다시 사용할 수 있는가? + +다중 해싱은 개방 주소법의 한 종류다. 개방 주소법은 모두 원소를 곧바로 삭제할 수 없으므로 삭제 표시를 해야 한다. 표시된 공간은 다시 사용할 수 있다. 새 원소를 삽입할 때 해시 함수로 삭제 표시가 있는 위치를 찾으면 그곳에 새 원소를 넣을 수 있다. 이렇게 하면 탐사 순서를 유지하면서 공간 활용률도 보장한다. + +**Q**: 선형 탐사에서는 원소를 찾을 때도 왜 해시 충돌이 발생하는가? + +해시 함수로 해당 버킷과 키-값 쌍을 찾았는데 `key`가 일치하지 않으면 해시 충돌이 있다는 뜻이다. 따라서 미리 정한 간격으로 뒤를 차례로 탐색하다가 올바른 키-값 쌍을 찾거나, 찾을 수 없다고 판단하면 종료한다. + +**Q**: 해시 테이블의 용량 확장은 왜 해시 충돌을 완화하는가? + +해시 함수는 마지막에 대개 배열 길이 $n$으로 나눈 나머지를 구하여 출력이 인덱스 범위에 들어가도록 한다. 용량을 늘리면 $n$이 바뀌고 `key`에 대응하는 인덱스도 달라질 수 있다. 원래 같은 버킷에 있던 여러 `key`가 여러 버킷으로 분산될 수 있으므로 충돌이 완화된다. + +**Q**: 효율적으로 저장하고 접근하려는 목적이라면 배열을 바로 사용하면 되지 않는가? + +`key`가 좁은 범위의 연속된 정수라면 배열을 바로 쓰는 것이 간단하고 효율적이다. 하지만 문자열처럼 다른 자료형이면 해시 함수로 `key`를 배열 인덱스에 매핑하고 버킷 배열에 원소를 저장해야 한다. 이것이 바로 해시 테이블이다. diff --git a/ko/docs/chapter_heap/build_heap.md b/ko/docs/chapter_heap/build_heap.md new file mode 100644 index 000000000..697b5764c --- /dev/null +++ b/ko/docs/chapter_heap/build_heap.md @@ -0,0 +1,376 @@ +--- +comments: true +--- + +# 8.2   힙 구성 연산 + +어떤 상황에서는 리스트의 모든 원소를 사용해 힙을 구성하고 싶을 수 있다. 이 과정을 '힙 구성 연산'이라고 한다. + +## 8.2.1   힙 삽입 연산을 이용한 구현 + +먼저 빈 힙을 만든 다음, 리스트를 순회하면서 각 원소에 대해 차례로 '힙 삽입 연산'을 수행한다. 즉, 원소를 힙의 끝에 추가한 뒤 해당 원소부터 '아래에서 위로' 힙을 재정렬한다. + +원소 하나를 힙에 삽입할 때마다 힙의 길이가 1씩 늘어난다. 이진 트리에 위에서 아래 순서로 노드를 추가하므로 힙은 '위에서 아래로' 구성된다. + +원소의 개수를 $n$이라고 하자. 각 원소의 힙 삽입 연산에는 $O(\log{n})$ 시간이 걸리므로, 이 힙 구성 방법의 시간 복잡도는 $O(n \log n)$이다. + +## 8.2.2   순회하며 힙을 재정렬하는 구현 + +실제로는 다음 두 단계로 더 효율적인 힙 구성 방법을 구현할 수 있다. + +1. 리스트의 모든 원소를 그대로 힙에 추가한다. 이 시점에서는 아직 힙의 성질을 만족하지 않는다. +2. 힙을 역순으로 순회하면서(레벨 순서 순회의 역순), 각 내부 노드에 대해 차례로 '위에서 아래로 힙 재정렬'을 수행한다. + +**노드 하나를 재정렬할 때마다 그 노드를 루트로 하는 서브트리가 올바른 부분 힙이 된다**. 역순으로 순회하므로 힙은 '아래에서 위로' 구성된다. + +역순으로 순회하는 이유는 현재 노드 아래의 서브트리들이 이미 올바른 부분 힙임을 보장하기 위해서다. 그래야 현재 노드를 재정렬하는 작업이 유효해진다. + +**리프 노드는 자식 노드가 없으므로 그 자체로 올바른 부분 힙이며, 재정렬할 필요가 없다**. 아래 코드와 같이 마지막 내부 노드는 마지막 노드의 부모 노드다. 여기서부터 역순으로 순회하며 힙 재정렬을 수행한다. + +=== "Python" + + ```python title="my_heap.py" + def __init__(self, nums: list[int]): + """생성자: 입력 리스트를 바탕으로 힙 구성""" + # 리스트 원소를 그대로 힙에 추가한다 + self.max_heap = nums + # 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for i in range(self.parent(self.size() - 1), -1, -1): + self.sift_down(i) + ``` + +=== "C++" + + ```cpp title="my_heap.cpp" + /* 생성자: 입력 리스트를 바탕으로 힙 구성 */ + MaxHeap(vector nums) { + // 리스트 원소를 그대로 힙에 추가한다 + maxHeap = nums; + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (int i = parent(size() - 1); i >= 0; i--) { + siftDown(i); + } + } + ``` + +=== "Java" + + ```java title="my_heap.java" + /* 생성자: 입력 리스트를 바탕으로 힙 구성 */ + MaxHeap(List nums) { + // 리스트 원소를 그대로 힙에 추가한다 + maxHeap = new ArrayList<>(nums); + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (int i = parent(size() - 1); i >= 0; i--) { + siftDown(i); + } + } + ``` + +=== "C#" + + ```csharp title="my_heap.cs" + /* 생성자: 입력 리스트로 힙 구성 */ + MaxHeap(IEnumerable nums) { + // 리스트 원소를 그대로 힙에 추가한다 + maxHeap = new List(nums); + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + var size = Parent(this.Size() - 1); + for (int i = size; i >= 0; i--) { + SiftDown(i); + } + } + ``` + +=== "Go" + + ```go title="my_heap.go" + /* 생성자: 슬라이스를 바탕으로 힙 구성 */ + func newMaxHeap(nums []any) *maxHeap { + // 리스트 원소를 그대로 힙에 추가한다 + h := &maxHeap{data: nums} + for i := h.parent(len(h.data) - 1); i >= 0; i-- { + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + h.siftDown(i) + } + return h + } + ``` + +=== "Swift" + + ```swift title="my_heap.swift" + /* 생성자: 입력 리스트를 바탕으로 힙 구성 */ + init(nums: [Int]) { + // 리스트 원소를 그대로 힙에 추가한다 + maxHeap = nums + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for i in (0 ... parent(i: size() - 1)).reversed() { + siftDown(i: i) + } + } + ``` + +=== "JS" + + ```javascript title="my_heap.js" + /* 생성자: 빈 힙을 만들거나 입력 리스트로 힙을 구성한다 */ + constructor(nums) { + // 리스트 원소를 그대로 힙에 추가한다 + this.#maxHeap = nums === undefined ? [] : [...nums]; + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (let i = this.#parent(this.size() - 1); i >= 0; i--) { + this.#siftDown(i); + } + } + ``` + +=== "TS" + + ```typescript title="my_heap.ts" + /* 생성자: 빈 힙을 만들거나 입력 리스트로 힙을 구성한다 */ + constructor(nums?: number[]) { + // 리스트 원소를 그대로 힙에 추가한다 + this.maxHeap = nums === undefined ? [] : [...nums]; + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (let i = this.parent(this.size() - 1); i >= 0; i--) { + this.siftDown(i); + } + } + ``` + +=== "Dart" + + ```dart title="my_heap.dart" + /* 생성자: 입력 리스트를 바탕으로 힙 구성 */ + MaxHeap(List nums) { + // 리스트 원소를 그대로 힙에 추가한다 + _maxHeap = nums; + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (int i = _parent(size() - 1); i >= 0; i--) { + siftDown(i); + } + } + ``` + +=== "Rust" + + ```rust title="my_heap.rs" + /* 생성자: 입력 리스트를 바탕으로 힙 구성 */ + fn new(nums: Vec) -> Self { + // 리스트 원소를 그대로 힙에 추가한다 + let mut heap = MaxHeap { max_heap: nums }; + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for i in (0..=Self::parent(heap.size() - 1)).rev() { + heap.sift_down(i); + } + heap + } + ``` + +=== "C" + + ```c title="my_heap.c" + /* 생성자: 슬라이스를 바탕으로 힙 구성 */ + MaxHeap *newMaxHeap(int nums[], int size) { + // 모든 원소를 힙에 삽입한다 + MaxHeap *maxHeap = (MaxHeap *)malloc(sizeof(MaxHeap)); + maxHeap->size = size; + memcpy(maxHeap->data, nums, size * sizeof(int)); + for (int i = parent(maxHeap, size - 1); i >= 0; i--) { + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + siftDown(maxHeap, i); + } + return maxHeap; + } + ``` + +=== "Kotlin" + + ```kotlin title="my_heap.kt" + /* 최대 힙 */ + class MaxHeap(nums: MutableList?) { + // 배열 대신 리스트를 사용하므로 용량 확장을 고려할 필요가 없다 + private val maxHeap = mutableListOf() + + /* 생성자: 입력 리스트를 바탕으로 힙 구성 */ + init { + // 리스트 원소를 그대로 힙에 추가한다 + maxHeap.addAll(nums!!) + // 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (i in parent(size() - 1) downTo 0) { + siftDown(i) + } + } + + /* 왼쪽 자식 노드의 인덱스 반환 */ + private fun left(i: Int): Int { + return 2 * i + 1 + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + private fun right(i: Int): Int { + return 2 * i + 2 + } + + /* 부모 노드의 인덱스 반환 */ + private fun parent(i: Int): Int { + return (i - 1) / 2 // 나눗셈의 결과를 내림한다 + } + + /* 원소 교환 */ + private fun swap(i: Int, j: Int) { + val temp = maxHeap[i] + maxHeap[i] = maxHeap[j] + maxHeap[j] = temp + } + + /* 힙 크기 반환 */ + fun size(): Int { + return maxHeap.size + } + + /* 힙이 비어 있는지 판단 */ + fun isEmpty(): Boolean { + /* 힙이 비어 있는지 판단 */ + return size() == 0 + } + + /* 힙의 루트 원소 접근 */ + fun peek(): Int { + return maxHeap[0] + } + + /* 힙에 원소 삽입 */ + fun push(_val: Int) { + // 노드 추가 + maxHeap.add(_val) + // 아래에서 위로 힙을 재정렬한다 + siftUp(size() - 1) + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + private fun siftUp(it: Int) { + // Kotlin의 함수 매개변수는 변경할 수 없으므로 임시 변수를 생성한다 + var i = it + while (true) { + // 노드 i의 부모 노드를 구한다 + val p = parent(i) + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if (p < 0 || maxHeap[i] <= maxHeap[p]) break + // 두 노드를 교환한다 + swap(i, p) + // 위쪽으로 힙 재정렬을 반복한다 + i = p + } + } + + /* 힙에서 원소 꺼내기 */ + fun pop(): Int { + // 비어 있는 경우를 처리한다 + if (isEmpty()) throw IndexOutOfBoundsException() + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + swap(0, size() - 1) + // 노드 삭제 + val _val = maxHeap.removeAt(size() - 1) + // 위에서 아래로 힙을 재정렬한다 + siftDown(0) + // 힙의 루트 원소를 반환한다 + return _val + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + private fun siftDown(it: Int) { + // Kotlin의 함수 매개변수는 변경할 수 없으므로 임시 변수를 생성한다 + var i = it + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + val l = left(i) + val r = right(i) + var ma = i + if (l < size() && maxHeap[l] > maxHeap[ma]) ma = l + if (r < size() && maxHeap[r] > maxHeap[ma]) ma = r + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) break + // 두 노드를 교환한다 + swap(i, ma) + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma + } + } + + /* 힙 출력(이진 트리) */ + fun print() { + val queue = PriorityQueue { a: Int, b: Int -> b - a } + queue.addAll(maxHeap) + printHeap(queue) + } + } + ``` + +=== "Ruby" + + ```ruby title="my_heap.rb" + ### 생성자: 입력 리스트를 바탕으로 힙 구성 ### + def initialize(nums) + # 리스트 원소를 그대로 힙에 추가한다 + @max_heap = nums + # 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + parent(size - 1).downto(0) do |i| + sift_down(i) + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 8.2.3   복잡도 분석 + +이제 두 번째 힙 구성 방법의 시간 복잡도를 계산해 보자. + +- 완전 이진 트리의 노드 수가 $n$이라면, 리프 노드의 수는 $(n + 1) / 2$이다. 여기서 $/$는 내림 정수 나눗셈이다. 따라서 재정렬해야 하는 노드의 수는 $n / 2$이다. +- 위에서 아래로 힙을 재정렬할 때 각 노드는 최대 리프 노드까지 내려갈 수 있으므로, 최대 반복 횟수는 이진 트리의 높이인 $\log n$이다. + +위의 두 값을 곱하면 힙 구성 과정의 시간 복잡도가 $O(n \log n)$이 된다. **하지만 이 추정은 정확하지 않다. 이진 트리에서는 아래쪽 노드의 수가 위쪽 노드보다 훨씬 많다는 성질을 고려하지 않았기 때문이다**. + +이제 더 정확하게 계산해 보자. 계산을 간단히 하기 위해 노드 수가 $n$이고 높이가 $h$인 '포화 이진 트리'가 주어졌다고 가정한다. 이 가정은 계산 결과의 정확성에 영향을 주지 않는다. + +![포화 이진 트리의 레벨별 노드 수](build_heap.assets/heapify_operations_count.png){ class="animation-figure" } + +

그림 8-5   포화 이진 트리의 레벨별 노드 수

+ +위 그림(8-5)에서 노드를 '위에서 아래로 재정렬'하는 최대 반복 횟수는 그 노드에서 리프 노드까지의 거리와 같으며, 이 거리는 바로 '노드의 높이'다. 따라서 각 레벨의 '노드 수 $\times$ 노드 높이'를 더하면 **모든 노드에 대한 힙 재정렬의 총반복 횟수**를 구할 수 있다. + +$$ +T(h) = 2^0h + 2^1(h-1) + 2^2(h-2) + \dots + 2^{(h-1)}\times1 +$$ + +위 식을 정리하려면 중등 수학의 수열 지식이 필요하다. 먼저 $T(h)$에 $2$를 곱하면 다음과 같다. + +$$ +\begin{aligned} +T(h) & = 2^0h + 2^1(h-1) + 2^2(h-2) + \dots + 2^{h-1}\times1 \newline +2 T(h) & = 2^1h + 2^2(h-1) + 2^3(h-2) + \dots + 2^{h}\times1 \newline +\end{aligned} +$$ + +두 식의 항을 한 칸씩 어긋나게 놓고 아래 식 $2 T(h)$에서 위 식 $T(h)$를 빼면 다음을 얻는다. + +$$ +2T(h) - T(h) = T(h) = -2^0h + 2^1 + 2^2 + \dots + 2^{h-1} + 2^h +$$ + +위 식에서 $T(h)$를 이루는 항들이 등비수열을 이루므로 합 공식을 바로 적용하여 시간 복잡도를 구할 수 있다. + +$$ +\begin{aligned} +T(h) & = 2 \frac{1 - 2^h}{1 - 2} - h \newline +& = 2^{h+1} - h - 2 \newline +& = O(2^h) +\end{aligned} +$$ + +또한 높이가 $h$인 포화 이진 트리의 노드 수는 $n = 2^{h+1} - 1$이므로, 복잡도가 $O(2^h) = O(n)$임을 쉽게 알 수 있다. 위 계산은 **리스트를 입력받아 힙을 구성하는 시간 복잡도가 $O(n)$으로 매우 효율적**임을 보여 준다. diff --git a/ko/docs/chapter_heap/exercises.md b/ko/docs/chapter_heap/exercises.md new file mode 100644 index 000000000..79069bba2 --- /dev/null +++ b/ko/docs/chapter_heap/exercises.md @@ -0,0 +1,90 @@ +--- +comments: true +--- + +# 8.5   연습 문제 + +## 8.5.1   개념 확인 + +### 1.   10을 힙에 넣은 뒤 어떻게 조정하는가 + +배열 `[9, 7, 8, 3, 5]`는 최대 힙을 나타낸다. 여기에 숫자 10을 삽입한다. + + + +1. 먼저 10을 배열 끝에 추가한다. 부모 노드의 값은 얼마인가? +2. 새 노드부터 위로 힙을 재정렬하며, 교환할 때마다 배열을 적어 보자. +3. 최종 루트 원소는 무엇이며, 총 몇 번 교환했는가? + +??? success "참고 답안" + + 1. 추가한 10의 인덱스는 5이고, 부모 인덱스는 + $\lfloor(5-1)/2\rfloor=2$이므로 부모 노드의 값은 8이다. + + 2. 10이 8보다 크므로 첫 번째 교환 후에는 `[9, 7, 10, 3, 5, 8]`이 된다. + 10은 부모 9보다도 크므로 두 번째 교환 후에는 `[10, 7, 9, 3, 5, 8]`이 된다. + 10이 루트에 도달했으므로 힙 재정렬을 마친다. + + 3. 최종 루트 값은 10이며, 총 2번 교환했다. + +### 2.   최소 힙의 부모와 자식 관계 확인하기 + +배열 `[1, 4, 3, 7, 6, 2]`는 완전 이진 트리를 나타낸다. 최소 힙에서는 모든 부모의 값이 자식의 값보다 작거나 같아야 한다. +인덱스 $i$의 왼쪽과 오른쪽 자식 인덱스는 각각 $2i+1$, $2i+2$다. + + + +1. 인덱스 2의 자식 인덱스와 값은 각각 무엇인가? +2. 인덱스 2의 값은 3이다. 이 노드와 자식의 관계가 최소 힙 규칙에 어긋나는가? 그렇다면 어떤 두 원소를 교환해야 하는가? +3. 2번의 판단에 따라, 규칙에 어긋나면 교환 후 배열을 적고 그렇지 않으면 교환할 필요가 없음을 설명하라. 마지막으로 나머지 부모와 자식 관계도 확인하라. + +??? success "참고 답안" + + 1. 인덱스 2의 왼쪽 자식 인덱스는 5이고 값은 2다. 오른쪽 자식 인덱스는 6이지만 배열 길이가 6이므로 오른쪽 자식은 없다. + + 2. 부모의 값 3이 자식의 값 2보다 크므로 최소 힙 규칙에 어긋난다. 인덱스 2와 5의 원소를 교환해야 한다. + + 3. 교환 후 배열은 `[1, 4, 2, 7, 6, 3]`이다. 관계를 하나씩 확인하면 + `1 ≤ 4`, `1 ≤ 2`, `4 ≤ 7`, `4 ≤ 6`, `2 ≤ 3`이다. + 모든 부모의 값이 자식의 값보다 작거나 같으므로 최소 힙 규칙을 만족한다. + +### 3.   최소 힙으로 가장 큰 수 세 개 유지하기 + +데이터 스트림 `[4, 1, 7, 3, 8]`에서 가장 큰 수 3개를 유지하려면 크기가 3을 넘지 않는 최소 힙을 사용할 수 있다. + +먼저 앞의 3개 수를 차례대로 최소 힙에 넣는다. 힙이 가득 찬 뒤에는 새 수를 읽을 때마다 +루트보다 크면 루트를 삭제하고 새 수를 넣는다. 그렇지 않으면 힙을 그대로 둔다. + +각 수를 읽은 뒤 힙에 남아 있는 수와 루트 값을 적어 보자. +남아 있는 수는 집합으로만 적으면 되며, 힙 배열에서의 순서는 적지 않아도 된다. + +??? success "참고 답안" + + 각 수를 읽은 뒤의 결과는 다음과 같다. + + | 읽은 수 | 남아 있는 수 | 루트 | + | --- | --- | --- | + | 4 | `{4}` | 4 | + | 1 | `{1, 4}` | 1 | + | 7 | `{1, 4, 7}` | 1 | + | 3 | `{3, 4, 7}` | 3 | + | 8 | `{4, 7, 8}` | 4 | + + 힙이 가득 차면 루트는 현재 남아 있는 수 중 가장 작은 수다. 새 수가 루트보다 클 때만 + 새 수로 루트를 대체한다. 최종적으로 남은 `{4, 7, 8}`이 바로 가장 큰 수 3개다. + +## 8.5.2   프로그래밍 연습 + +### 1.   배열에서 k번째로 큰 원소 찾기 + +정수 배열 `nums`와 정수 $k$가 주어진다. 여기서 $1 \le k \le n$이며 $n$은 배열 길이다. 배열을 큰 것부터 나열했을 때 $k$번째 위치의 원소를 반환하라. + +중복 원소도 각각 센다. 예를 들어 `[5, 5, 2]`에서 두 번째로 큰 원소 역시 5다. 크기가 $k$를 넘지 않는 최소 힙을 사용하여 해결하라. + +??? tip "풀이 힌트" + + 1. k번째로 큰 원소는 가장 큰 k개 수 중 가장 작은 수다. + 2. 각 수를 최소 힙에 넣고 크기가 k를 넘으면 최솟값을 꺼낸다. + 3. 순회가 끝나면 힙에 가장 큰 k개 수가 남으며, 루트가 답이다. + +[LeetCode](https://leetcode.com/problems/kth-largest-element-in-an-array/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_heap/heap.md b/ko/docs/chapter_heap/heap.md new file mode 100644 index 000000000..b4e7fc50c --- /dev/null +++ b/ko/docs/chapter_heap/heap.md @@ -0,0 +1,1780 @@ +--- +comments: true +--- + +# 8.1   힙 + +힙(heap)은 특정 조건을 만족하는 완전 이진 트리로, 아래 그림(8-1)과 같이 크게 두 종류로 나뉜다. + +- 최소 힙(min heap): 모든 노드의 값이 자식 노드의 값보다 작거나 같다($\leq$). +- 최대 힙(max heap): 모든 노드의 값이 자식 노드의 값보다 크거나 같다($\geq$). + +![최소 힙과 최대 힙](heap.assets/min_heap_and_max_heap.png){ class="animation-figure" } + +

그림 8-1   최소 힙과 최대 힙

+ +힙은 완전 이진 트리의 특수한 형태이므로 다음 성질을 가진다. + +- 가장 아래 레벨은 왼쪽부터 노드가 채워지며, 나머지 레벨은 모두 가득 차 있다. +- 이진 트리의 루트 노드를 '힙의 루트', 가장 아래 레벨에서 맨 오른쪽에 있는 노드를 '힙의 끝'이라고 한다. +- 최대 힙(최소 힙)에서는 힙의 루트 원소(루트 노드)의 값이 가장 크다(작다). + +## 8.1.1   힙의 주요 연산 + +많은 프로그래밍 언어에서 제공하는 것은 우선순위 큐(priority queue)라는 점을 알아 두자. 우선순위 큐는 우선순위에 따라 정렬되는 큐로 정의되는 추상 자료 구조다. + +실제로 **힙은 보통 우선순위 큐를 구현하는 데 사용되며, 최대 힙은 원소를 큰 값부터 작은 값 순서로 꺼내는 우선순위 큐에 해당한다**. 사용하는 입장에서는 '우선순위 큐'와 '힙'을 동등한 자료 구조로 볼 수 있다. 따라서 이 책에서는 둘을 특별히 구분하지 않고 모두 '힙'이라고 한다. + +힙의 주요 연산은 아래 표(8-1)와 같다. 메서드 이름은 프로그래밍 언어에 따라 달라진다. + +

표 8-1   힙 연산의 효율

+ +
+ +| 메서드 이름 | 설명 | 시간 복잡도 | +| ----------- | ------------------------------------------------ | ----------- | +| `push()` | 힙에 원소 삽입 | $O(\log n)$ | +| `pop()` | 힙의 루트 원소 꺼내기 | $O(\log n)$ | +| `peek()` | 힙의 루트 원소에 접근(최대 / 최소 힙에서는 각각 최댓값 / 최솟값) | $O(1)$ | +| `size()` | 힙의 원소 수 구하기 | $O(1)$ | +| `isEmpty()` | 힙이 비어 있는지 확인 | $O(1)$ | + +
+ +실제 응용에서는 프로그래밍 언어가 제공하는 힙 클래스(또는 우선순위 큐 클래스)를 바로 사용할 수 있다. + +정렬 알고리즘에서 '오름차순 정렬'과 '내림차순 정렬'을 선택하듯이, `flag`를 설정하거나 `Comparator`를 수정하여 '최소 힙'과 '최대 힙'을 전환할 수 있다. 코드는 다음과 같다. + +=== "Python" + + ```python title="heap.py" + # 최소 힙 초기화 + min_heap, flag = [], 1 + # 최대 힙 초기화 + max_heap, flag = [], -1 + + # Python의 heapq 모듈은 기본적으로 최소 힙을 구현한다 + # 원소의 부호를 바꾼 뒤 삽입하면 대소 관계가 뒤집히므로 최대 힙을 구현할 수 있다 + # 이 예제에서 flag = 1이면 최소 힙, flag = -1이면 최대 힙에 해당한다 + + # 힙에 원소 삽입 + heapq.heappush(max_heap, flag * 1) + heapq.heappush(max_heap, flag * 3) + heapq.heappush(max_heap, flag * 2) + heapq.heappush(max_heap, flag * 5) + heapq.heappush(max_heap, flag * 4) + + # 힙의 루트 원소 구하기 + peek: int = flag * max_heap[0] # 5 + + # 힙의 루트 원소 꺼내기 + # 꺼낸 원소들은 큰 값부터 작은 값 순서로 나열된다 + val = flag * heapq.heappop(max_heap) # 5 + val = flag * heapq.heappop(max_heap) # 4 + val = flag * heapq.heappop(max_heap) # 3 + val = flag * heapq.heappop(max_heap) # 2 + val = flag * heapq.heappop(max_heap) # 1 + + # 힙의 크기 구하기 + size: int = len(max_heap) + + # 힙이 비어 있는지 확인 + is_empty: bool = not max_heap + + # 리스트를 입력받아 힙 구성 + min_heap: list[int] = [1, 3, 2, 5, 4] + heapq.heapify(min_heap) + ``` + +=== "C++" + + ```cpp title="heap.cpp" + /* 힙 초기화 */ + // 최소 힙 초기화 + priority_queue, greater> minHeap; + // 최대 힙 초기화 + priority_queue, less> maxHeap; + + /* 힙에 원소 삽입 */ + maxHeap.push(1); + maxHeap.push(3); + maxHeap.push(2); + maxHeap.push(5); + maxHeap.push(4); + + /* 힙의 루트 원소 구하기 */ + int peek = maxHeap.top(); // 5 + + /* 힙의 루트 원소 꺼내기 */ + // 꺼낸 원소들은 큰 값부터 작은 값 순서로 나열된다 + maxHeap.pop(); // 5 + maxHeap.pop(); // 4 + maxHeap.pop(); // 3 + maxHeap.pop(); // 2 + maxHeap.pop(); // 1 + + /* 힙의 크기 구하기 */ + int size = maxHeap.size(); + + /* 힙이 비어 있는지 확인 */ + bool isEmpty = maxHeap.empty(); + + /* 리스트를 입력받아 힙 구성 */ + vector input{1, 3, 2, 5, 4}; + priority_queue, greater> minHeap(input.begin(), input.end()); + ``` + +=== "Java" + + ```java title="heap.java" + /* 힙 초기화 */ + // 최소 힙 초기화 + Queue minHeap = new PriorityQueue<>(); + // 최대 힙 초기화(lambda 식으로 Comparator를 수정하면 된다) + Queue maxHeap = new PriorityQueue<>((a, b) -> b - a); + + /* 힙에 원소 삽입 */ + maxHeap.offer(1); + maxHeap.offer(3); + maxHeap.offer(2); + maxHeap.offer(5); + maxHeap.offer(4); + + /* 힙의 루트 원소 구하기 */ + int peek = maxHeap.peek(); // 5 + + /* 힙의 루트 원소 꺼내기 */ + // 꺼낸 원소들은 큰 값부터 작은 값 순서로 나열된다 + peek = maxHeap.poll(); // 5 + peek = maxHeap.poll(); // 4 + peek = maxHeap.poll(); // 3 + peek = maxHeap.poll(); // 2 + peek = maxHeap.poll(); // 1 + + /* 힙의 크기 구하기 */ + int size = maxHeap.size(); + + /* 힙이 비어 있는지 확인 */ + boolean isEmpty = maxHeap.isEmpty(); + + /* 리스트를 입력받아 힙 구성 */ + minHeap = new PriorityQueue<>(Arrays.asList(1, 3, 2, 5, 4)); + ``` + +=== "C#" + + ```csharp title="heap.cs" + /* 힙 초기화 */ + // 최소 힙 초기화 + PriorityQueue minHeap = new(); + // 최대 힙 초기화(lambda 식으로 Comparer를 수정하면 된다) + PriorityQueue maxHeap = new(Comparer.Create((x, y) => y.CompareTo(x))); + + /* 힙에 원소 삽입 */ + maxHeap.Enqueue(1, 1); + maxHeap.Enqueue(3, 3); + maxHeap.Enqueue(2, 2); + maxHeap.Enqueue(5, 5); + maxHeap.Enqueue(4, 4); + + /* 힙의 루트 원소 구하기 */ + int peek = maxHeap.Peek();//5 + + /* 힙의 루트 원소 꺼내기 */ + // 꺼낸 원소들은 큰 값부터 작은 값 순서로 나열된다 + peek = maxHeap.Dequeue(); // 5 + peek = maxHeap.Dequeue(); // 4 + peek = maxHeap.Dequeue(); // 3 + peek = maxHeap.Dequeue(); // 2 + peek = maxHeap.Dequeue(); // 1 + + /* 힙의 크기 구하기 */ + int size = maxHeap.Count; + + /* 힙이 비어 있는지 확인 */ + bool isEmpty = maxHeap.Count == 0; + + /* 리스트를 입력받아 힙 구성 */ + minHeap = new PriorityQueue([(1, 1), (3, 3), (2, 2), (5, 5), (4, 4)]); + ``` + +=== "Go" + + ```go title="heap.go" + // Go에서는 heap.Interface를 구현하여 정수 최대 힙을 만들 수 있다 + // heap.Interface를 구현하려면 sort.Interface도 구현해야 한다 + type intHeap []any + + // Push는 heap.Interface의 메서드로, 힙에 원소를 삽입한다 + func (h *intHeap) Push(x any) { + // Push와 Pop은 포인터 리시버(pointer receiver)를 매개변수로 사용한다 + // 슬라이스의 내용뿐 아니라 길이도 수정하기 때문이다. + *h = append(*h, x.(int)) + } + + // Pop은 heap.Interface의 메서드로, 힙의 루트 원소를 꺼낸다 + func (h *intHeap) Pop() any { + // 꺼낼 원소는 맨 끝에 저장되어 있다 + last := (*h)[len(*h)-1] + *h = (*h)[:len(*h)-1] + return last + } + + // Len은 sort.Interface의 메서드다 + func (h *intHeap) Len() int { + return len(*h) + } + + // Less는 sort.Interface의 메서드다 + func (h *intHeap) Less(i, j int) bool { + // 최소 힙을 구현하려면 부등호를 <로 바꿔야 한다 + return (*h)[i].(int) > (*h)[j].(int) + } + + // Swap은 sort.Interface의 메서드다 + func (h *intHeap) Swap(i, j int) { + (*h)[i], (*h)[j] = (*h)[j], (*h)[i] + } + + // Top은 힙의 루트 원소를 구한다 + func (h *intHeap) Top() any { + return (*h)[0] + } + + /* 실행 코드 */ + func TestHeap(t *testing.T) { + /* 힙 초기화 */ + // 최대 힙 초기화 + maxHeap := &intHeap{} + heap.Init(maxHeap) + /* 힙에 원소 삽입 */ + // heap.Interface의 메서드를 호출하여 원소를 추가한다 + heap.Push(maxHeap, 1) + heap.Push(maxHeap, 3) + heap.Push(maxHeap, 2) + heap.Push(maxHeap, 4) + heap.Push(maxHeap, 5) + + /* 힙의 루트 원소 구하기 */ + top := maxHeap.Top() + fmt.Printf("힙의 루트 원소: %d\n", top) + + /* 힙의 루트 원소 꺼내기 */ + // heap.Interface의 메서드를 호출하여 원소를 제거한다 + heap.Pop(maxHeap) // 5 + heap.Pop(maxHeap) // 4 + heap.Pop(maxHeap) // 3 + heap.Pop(maxHeap) // 2 + heap.Pop(maxHeap) // 1 + + /* 힙의 크기 구하기 */ + size := len(*maxHeap) + fmt.Printf("힙의 원소 수: %d\n", size) + + /* 힙이 비어 있는지 확인 */ + isEmpty := len(*maxHeap) == 0 + fmt.Printf("힙이 비어 있는가: %t\n", isEmpty) + } + ``` + +=== "Swift" + + ```swift title="heap.swift" + /* 힙 초기화 */ + // Swift의 Heap 타입은 최대 힙과 최소 힙을 모두 지원하며, swift-collections를 가져와야 한다 + var heap = Heap() + + /* 힙에 원소 삽입 */ + heap.insert(1) + heap.insert(3) + heap.insert(2) + heap.insert(5) + heap.insert(4) + + /* 힙의 루트 원소 구하기 */ + var peek = heap.max()! + + /* 힙의 루트 원소 꺼내기 */ + peek = heap.removeMax() // 5 + peek = heap.removeMax() // 4 + peek = heap.removeMax() // 3 + peek = heap.removeMax() // 2 + peek = heap.removeMax() // 1 + + /* 힙의 크기 구하기 */ + let size = heap.count + + /* 힙이 비어 있는지 확인 */ + let isEmpty = heap.isEmpty + + /* 리스트를 입력받아 힙 구성 */ + let heap2 = Heap([1, 3, 2, 5, 4]) + ``` + +=== "JS" + + ```javascript title="heap.js" + // JavaScript는 내장 Heap 클래스를 제공하지 않는다 + ``` + +=== "TS" + + ```typescript title="heap.ts" + // TypeScript는 내장 Heap 클래스를 제공하지 않는다 + ``` + +=== "Dart" + + ```dart title="heap.dart" + // Dart는 내장 Heap 클래스를 제공하지 않는다 + ``` + +=== "Rust" + + ```rust title="heap.rs" + use std::collections::BinaryHeap; + use std::cmp::Reverse; + + /* 힙 초기화 */ + // 최소 힙 초기화 + let mut min_heap = BinaryHeap::>::new(); + // 최대 힙 초기화 + let mut max_heap = BinaryHeap::new(); + + /* 힙에 원소 삽입 */ + max_heap.push(1); + max_heap.push(3); + max_heap.push(2); + max_heap.push(5); + max_heap.push(4); + + /* 힙의 루트 원소 구하기 */ + let peek = max_heap.peek().unwrap(); // 5 + + /* 힙의 루트 원소 꺼내기 */ + // 꺼낸 원소들은 큰 값부터 작은 값 순서로 나열된다 + let peek = max_heap.pop().unwrap(); // 5 + let peek = max_heap.pop().unwrap(); // 4 + let peek = max_heap.pop().unwrap(); // 3 + let peek = max_heap.pop().unwrap(); // 2 + let peek = max_heap.pop().unwrap(); // 1 + + /* 힙의 크기 구하기 */ + let size = max_heap.len(); + + /* 힙이 비어 있는지 확인 */ + let is_empty = max_heap.is_empty(); + + /* 리스트를 입력받아 힙 구성 */ + let min_heap = BinaryHeap::from(vec![Reverse(1), Reverse(3), Reverse(2), Reverse(5), Reverse(4)]); + ``` + +=== "C" + + ```c title="heap.c" + // C는 내장 Heap 클래스를 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="heap.kt" + /* 힙 초기화 */ + // 최소 힙 초기화 + var minHeap = PriorityQueue() + // 최대 힙 초기화(lambda 식으로 Comparator를 수정하면 된다) + val maxHeap = PriorityQueue { a: Int, b: Int -> b - a } + + /* 힙에 원소 삽입 */ + maxHeap.offer(1) + maxHeap.offer(3) + maxHeap.offer(2) + maxHeap.offer(5) + maxHeap.offer(4) + + /* 힙의 루트 원소 구하기 */ + var peek = maxHeap.peek() // 5 + + /* 힙의 루트 원소 꺼내기 */ + // 꺼낸 원소들은 큰 값부터 작은 값 순서로 나열된다 + peek = maxHeap.poll() // 5 + peek = maxHeap.poll() // 4 + peek = maxHeap.poll() // 3 + peek = maxHeap.poll() // 2 + peek = maxHeap.poll() // 1 + + /* 힙의 크기 구하기 */ + val size = maxHeap.size + + /* 힙이 비어 있는지 확인 */ + val isEmpty = maxHeap.isEmpty() + + /* 리스트를 입력받아 힙 구성 */ + minHeap = PriorityQueue(mutableListOf(1, 3, 2, 5, 4)) + ``` + +=== "Ruby" + + ```ruby title="heap.rb" + # Ruby는 내장 Heap 클래스를 제공하지 않는다 + ``` + +??? pythontutor "실행 시각화" + +
+ + +## 8.1.2   힙의 구현 + +아래에서는 최대 힙을 구현한다. 이를 최소 힙으로 바꾸려면 모든 대소 비교 조건을 반대로 바꾸면 된다(예를 들어 $\geq$를 $\leq$로 바꾼다). 관심 있는 독자는 직접 구현해 보자. + +### 1.   힙의 저장과 표현 + +'이진 트리' 장에서 살펴보았듯이 완전 이진 트리는 배열로 표현하기에 매우 적합하다. 힙은 완전 이진 트리이므로 **배열을 사용하여 힙을 저장한다**. + +배열로 이진 트리를 표현할 때 원소는 노드의 값을, 인덱스는 이진 트리에서 노드의 위치를 나타낸다. **노드 포인터는 인덱스 매핑 공식으로 구현한다**. + +아래 그림(8-2)과 같이 인덱스 $i$가 주어지면 왼쪽 자식 노드의 인덱스는 $2i + 1$, 오른쪽 자식 노드의 인덱스는 $2i + 2$, 부모 노드의 인덱스는 $(i - 1) / 2$(내림 정수 나눗셈)이다. 인덱스가 범위를 벗어나면 빈 노드이거나 노드가 존재하지 않는다는 뜻이다. + +![힙의 표현과 저장](heap.assets/representation_of_heap.png){ class="animation-figure" } + +

그림 8-2   힙의 표현과 저장

+ +나중에 편리하게 사용할 수 있도록 인덱스 매핑 공식을 함수로 만들 수 있다. + +=== "Python" + + ```python title="my_heap.py" + def left(self, i: int) -> int: + """왼쪽 자식 노드의 인덱스 반환""" + return 2 * i + 1 + + def right(self, i: int) -> int: + """오른쪽 자식 노드의 인덱스 반환""" + return 2 * i + 2 + + def parent(self, i: int) -> int: + """부모 노드의 인덱스 반환""" + return (i - 1) // 2 # 나눗셈의 결과를 내림한다 + ``` + +=== "C++" + + ```cpp title="my_heap.cpp" + /* 왼쪽 자식 노드의 인덱스 반환 */ + int left(int i) { + return 2 * i + 1; + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + int right(int i) { + return 2 * i + 2; + } + + /* 부모 노드의 인덱스 반환 */ + int parent(int i) { + return (i - 1) / 2; // 나눗셈의 결과를 내림한다 + } + ``` + +=== "Java" + + ```java title="my_heap.java" + /* 왼쪽 자식 노드의 인덱스 반환 */ + int left(int i) { + return 2 * i + 1; + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + int right(int i) { + return 2 * i + 2; + } + + /* 부모 노드의 인덱스 반환 */ + int parent(int i) { + return (i - 1) / 2; // 나눗셈의 결과를 내림한다 + } + ``` + +=== "C#" + + ```csharp title="my_heap.cs" + /* 왼쪽 자식 노드의 인덱스 반환 */ + int Left(int i) { + return 2 * i + 1; + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + int Right(int i) { + return 2 * i + 2; + } + + /* 부모 노드의 인덱스 반환 */ + int Parent(int i) { + return (i - 1) / 2; // 나눗셈의 결과를 내림한다 + } + ``` + +=== "Go" + + ```go title="my_heap.go" + /* 왼쪽 자식 노드의 인덱스 반환 */ + func (h *maxHeap) left(i int) int { + return 2*i + 1 + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + func (h *maxHeap) right(i int) int { + return 2*i + 2 + } + + /* 부모 노드의 인덱스 반환 */ + func (h *maxHeap) parent(i int) int { + // 나눗셈의 결과를 내림한다 + return (i - 1) / 2 + } + ``` + +=== "Swift" + + ```swift title="my_heap.swift" + /* 왼쪽 자식 노드의 인덱스 반환 */ + func left(i: Int) -> Int { + 2 * i + 1 + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + func right(i: Int) -> Int { + 2 * i + 2 + } + + /* 부모 노드의 인덱스 반환 */ + func parent(i: Int) -> Int { + (i - 1) / 2 // 나눗셈의 결과를 내림한다 + } + ``` + +=== "JS" + + ```javascript title="my_heap.js" + /* 왼쪽 자식 노드의 인덱스 반환 */ + #left(i) { + return 2 * i + 1; + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + #right(i) { + return 2 * i + 2; + } + + /* 부모 노드의 인덱스 반환 */ + #parent(i) { + return Math.floor((i - 1) / 2); // 나눗셈의 결과를 내림한다 + } + ``` + +=== "TS" + + ```typescript title="my_heap.ts" + /* 왼쪽 자식 노드의 인덱스 반환 */ + left(i: number): number { + return 2 * i + 1; + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + right(i: number): number { + return 2 * i + 2; + } + + /* 부모 노드의 인덱스 반환 */ + parent(i: number): number { + return Math.floor((i - 1) / 2); // 나눗셈의 결과를 내림한다 + } + ``` + +=== "Dart" + + ```dart title="my_heap.dart" + /* 왼쪽 자식 노드의 인덱스 반환 */ + int _left(int i) { + return 2 * i + 1; + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + int _right(int i) { + return 2 * i + 2; + } + + /* 부모 노드의 인덱스 반환 */ + int _parent(int i) { + return (i - 1) ~/ 2; // 나눗셈의 결과를 내림한다 + } + ``` + +=== "Rust" + + ```rust title="my_heap.rs" + /* 왼쪽 자식 노드의 인덱스 반환 */ + fn left(i: usize) -> usize { + 2 * i + 1 + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + fn right(i: usize) -> usize { + 2 * i + 2 + } + + /* 부모 노드의 인덱스 반환 */ + fn parent(i: usize) -> usize { + (i - 1) / 2 // 나눗셈의 결과를 내림한다 + } + ``` + +=== "C" + + ```c title="my_heap.c" + /* 왼쪽 자식 노드의 인덱스 반환 */ + int left(MaxHeap *maxHeap, int i) { + return 2 * i + 1; + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + int right(MaxHeap *maxHeap, int i) { + return 2 * i + 2; + } + + /* 부모 노드의 인덱스 반환 */ + int parent(MaxHeap *maxHeap, int i) { + return (i - 1) / 2; // 내림 + } + ``` + +=== "Kotlin" + + ```kotlin title="my_heap.kt" + /* 왼쪽 자식 노드의 인덱스 반환 */ + fun left(i: Int): Int { + return 2 * i + 1 + } + + /* 오른쪽 자식 노드의 인덱스 반환 */ + fun right(i: Int): Int { + return 2 * i + 2 + } + + /* 부모 노드의 인덱스 반환 */ + fun parent(i: Int): Int { + return (i - 1) / 2 // 나눗셈의 결과를 내림한다 + } + ``` + +=== "Ruby" + + ```ruby title="my_heap.rb" + ### 왼쪽 자식 노드의 인덱스 반환 ### + def left(i) + 2 * i + 1 + end + + ### 오른쪽 자식 노드의 인덱스 반환 ### + def right(i) + 2 * i + 2 + end + + ### 부모 노드의 인덱스 반환 ### + def parent(i) + (i - 1) / 2 # 나눗셈의 결과를 내림한다 + end + ``` + +### 2.   힙의 루트 원소에 접근 + +힙의 루트 원소는 이진 트리의 루트 노드, 즉 리스트의 첫 번째 원소다. + +=== "Python" + + ```python title="my_heap.py" + def peek(self) -> int: + """힙의 루트 원소 접근""" + return self.max_heap[0] + ``` + +=== "C++" + + ```cpp title="my_heap.cpp" + /* 힙의 루트 원소 접근 */ + int peek() { + return maxHeap[0]; + } + ``` + +=== "Java" + + ```java title="my_heap.java" + /* 힙의 루트 원소 접근 */ + int peek() { + return maxHeap.get(0); + } + ``` + +=== "C#" + + ```csharp title="my_heap.cs" + /* 힙의 루트 원소 접근 */ + int Peek() { + return maxHeap[0]; + } + ``` + +=== "Go" + + ```go title="my_heap.go" + /* 힙의 루트 원소 접근 */ + func (h *maxHeap) peek() any { + return h.data[0] + } + ``` + +=== "Swift" + + ```swift title="my_heap.swift" + /* 힙의 루트 원소 접근 */ + func peek() -> Int { + maxHeap[0] + } + ``` + +=== "JS" + + ```javascript title="my_heap.js" + /* 힙의 루트 원소 접근 */ + peek() { + return this.#maxHeap[0]; + } + ``` + +=== "TS" + + ```typescript title="my_heap.ts" + /* 힙의 루트 원소 접근 */ + peek(): number { + return this.maxHeap[0]; + } + ``` + +=== "Dart" + + ```dart title="my_heap.dart" + /* 힙의 루트 원소 접근 */ + int peek() { + return _maxHeap[0]; + } + ``` + +=== "Rust" + + ```rust title="my_heap.rs" + /* 힙의 루트 원소 접근 */ + fn peek(&self) -> Option { + self.max_heap.first().copied() + } + ``` + +=== "C" + + ```c title="my_heap.c" + /* 힙의 루트 원소 접근 */ + int peek(MaxHeap *maxHeap) { + return maxHeap->data[0]; + } + ``` + +=== "Kotlin" + + ```kotlin title="my_heap.kt" + /* 힙의 루트 원소 접근 */ + fun peek(): Int { + return maxHeap[0] + } + ``` + +=== "Ruby" + + ```ruby title="my_heap.rb" + ### 힙의 루트 원소 접근 ### + def peek + @max_heap[0] + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 3.   힙에 원소 삽입 + +원소 `val`이 주어지면 먼저 힙의 끝에 추가한다. 추가한 `val`이 힙의 다른 원소보다 클 수 있으므로, 힙의 성립 조건이 깨질 수 있다. **따라서 삽입한 노드에서 루트 노드까지의 경로에 있는 노드들을 조정해야 한다**. 이 연산을 힙 재정렬(heapify)이라고 한다. + +삽입한 노드부터 **아래에서 위로 힙을 재정렬**해 보자. 아래 그림(8-3)과 같이 삽입한 노드의 값과 부모 노드의 값을 비교하여 삽입한 노드가 더 크면 서로 바꾼다. 이 작업을 반복하여 아래에서 위로 힙의 노드들을 조정하고, 루트 노드를 지나거나 바꿀 필요가 없는 노드를 만나면 종료한다. + +=== "<1>" + ![힙에 원소를 삽입하는 단계](heap.assets/heap_push_step1.png){ class="animation-figure" } + +=== "<2>" + ![힙 삽입 2단계](heap.assets/heap_push_step2.png){ class="animation-figure" } + +=== "<3>" + ![힙 삽입 3단계](heap.assets/heap_push_step3.png){ class="animation-figure" } + +=== "<4>" + ![힙 삽입 4단계](heap.assets/heap_push_step4.png){ class="animation-figure" } + +=== "<5>" + ![힙 삽입 5단계](heap.assets/heap_push_step5.png){ class="animation-figure" } + +=== "<6>" + ![힙 삽입 6단계](heap.assets/heap_push_step6.png){ class="animation-figure" } + +=== "<7>" + ![힙 삽입 7단계](heap.assets/heap_push_step7.png){ class="animation-figure" } + +=== "<8>" + ![힙 삽입 8단계](heap.assets/heap_push_step8.png){ class="animation-figure" } + +=== "<9>" + ![힙 삽입 9단계](heap.assets/heap_push_step9.png){ class="animation-figure" } + +

그림 8-3   힙에 원소를 삽입하는 단계

+ +전체 노드 수가 $n$이면 트리의 높이는 $O(\log n)$이다. 따라서 힙 재정렬의 반복 횟수는 최대 $O(\log n)$이며, **힙에 원소를 삽입하는 연산의 시간 복잡도는 $O(\log n)$**이다. 코드는 다음과 같다. + +=== "Python" + + ```python title="my_heap.py" + def push(self, val: int): + """힙에 원소 삽입""" + # 노드 추가 + self.max_heap.append(val) + # 아래에서 위로 힙을 재정렬한다 + self.sift_up(self.size() - 1) + + def sift_up(self, i: int): + """노드 i에서 시작하여 아래에서 위로 힙 재정렬""" + while True: + # 노드 i의 부모 노드를 구한다 + p = self.parent(i) + # 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if p < 0 or self.max_heap[i] <= self.max_heap[p]: + break + # 두 노드를 교환한다 + self.swap(i, p) + # 위쪽으로 힙 재정렬을 반복한다 + i = p + ``` + +=== "C++" + + ```cpp title="my_heap.cpp" + /* 힙에 원소 삽입 */ + void push(int val) { + // 노드 추가 + maxHeap.push_back(val); + // 아래에서 위로 힙을 재정렬한다 + siftUp(size() - 1); + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + void siftUp(int i) { + while (true) { + // 노드 i의 부모 노드를 구한다 + int p = parent(i); + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if (p < 0 || maxHeap[i] <= maxHeap[p]) + break; + // 두 노드를 교환한다 + swap(maxHeap[i], maxHeap[p]); + // 위쪽으로 힙 재정렬을 반복한다 + i = p; + } + } + ``` + +=== "Java" + + ```java title="my_heap.java" + /* 힙에 원소 삽입 */ + void push(int val) { + // 노드 추가 + maxHeap.add(val); + // 아래에서 위로 힙을 재정렬한다 + siftUp(size() - 1); + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + void siftUp(int i) { + while (true) { + // 노드 i의 부모 노드를 구한다 + int p = parent(i); + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if (p < 0 || maxHeap.get(i) <= maxHeap.get(p)) + break; + // 두 노드를 교환한다 + swap(i, p); + // 위쪽으로 힙 재정렬을 반복한다 + i = p; + } + } + ``` + +=== "C#" + + ```csharp title="my_heap.cs" + /* 힙에 원소 삽입 */ + void Push(int val) { + // 노드 추가 + maxHeap.Add(val); + // 아래에서 위로 힙을 재정렬한다 + SiftUp(Size() - 1); + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + void SiftUp(int i) { + while (true) { + // 노드 i의 부모 노드를 구한다 + int p = Parent(i); + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 종료한다 + if (p < 0 || maxHeap[i] <= maxHeap[p]) + break; + // 두 노드를 교환한다 + Swap(i, p); + // 위쪽으로 힙 재정렬을 반복한다 + i = p; + } + } + ``` + +=== "Go" + + ```go title="my_heap.go" + /* 힙에 원소 삽입 */ + func (h *maxHeap) push(val any) { + // 노드 추가 + h.data = append(h.data, val) + // 아래에서 위로 힙을 재정렬한다 + h.siftUp(len(h.data) - 1) + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + func (h *maxHeap) siftUp(i int) { + for true { + // 노드 i의 부모 노드를 구한다 + p := h.parent(i) + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if p < 0 || h.data[i].(int) <= h.data[p].(int) { + break + } + // 두 노드를 교환한다 + h.swap(i, p) + // 위쪽으로 힙 재정렬을 반복한다 + i = p + } + } + ``` + +=== "Swift" + + ```swift title="my_heap.swift" + /* 힙에 원소 삽입 */ + func push(val: Int) { + // 노드 추가 + maxHeap.append(val) + // 아래에서 위로 힙을 재정렬한다 + siftUp(i: size() - 1) + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + func siftUp(i: Int) { + var i = i + while true { + // 노드 i의 부모 노드를 구한다 + let p = parent(i: i) + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if p < 0 || maxHeap[i] <= maxHeap[p] { + break + } + // 두 노드를 교환한다 + swap(i: i, j: p) + // 위쪽으로 힙 재정렬을 반복한다 + i = p + } + } + ``` + +=== "JS" + + ```javascript title="my_heap.js" + /* 힙에 원소 삽입 */ + push(val) { + // 노드 추가 + this.#maxHeap.push(val); + // 아래에서 위로 힙을 재정렬한다 + this.#siftUp(this.size() - 1); + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + #siftUp(i) { + while (true) { + // 노드 i의 부모 노드를 구한다 + const p = this.#parent(i); + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if (p < 0 || this.#maxHeap[i] <= this.#maxHeap[p]) break; + // 두 노드를 교환한다 + this.#swap(i, p); + // 위쪽으로 힙 재정렬을 반복한다 + i = p; + } + } + ``` + +=== "TS" + + ```typescript title="my_heap.ts" + /* 힙에 원소 삽입 */ + push(val: number): void { + // 노드 추가 + this.maxHeap.push(val); + // 아래에서 위로 힙을 재정렬한다 + this.siftUp(this.size() - 1); + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + siftUp(i: number): void { + while (true) { + // 노드 i의 부모 노드를 구한다 + const p = this.parent(i); + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if (p < 0 || this.maxHeap[i] <= this.maxHeap[p]) break; + // 두 노드를 교환한다 + this.swap(i, p); + // 위쪽으로 힙 재정렬을 반복한다 + i = p; + } + } + ``` + +=== "Dart" + + ```dart title="my_heap.dart" + /* 힙에 원소 삽입 */ + void push(int val) { + // 노드 추가 + _maxHeap.add(val); + // 아래에서 위로 힙을 재정렬한다 + siftUp(size() - 1); + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + void siftUp(int i) { + while (true) { + // 노드 i의 부모 노드를 구한다 + int p = _parent(i); + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if (p < 0 || _maxHeap[i] <= _maxHeap[p]) { + break; + } + // 두 노드를 교환한다 + _swap(i, p); + // 위쪽으로 힙 재정렬을 반복한다 + i = p; + } + } + ``` + +=== "Rust" + + ```rust title="my_heap.rs" + /* 힙에 원소 삽입 */ + fn push(&mut self, val: i32) { + // 노드 추가 + self.max_heap.push(val); + // 아래에서 위로 힙을 재정렬한다 + self.sift_up(self.size() - 1); + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + fn sift_up(&mut self, mut i: usize) { + loop { + // 노드 i가 이미 힙의 루트이므로 힙 재정렬을 마친다 + if i == 0 { + break; + } + // 노드 i의 부모 노드를 구한다 + let p = Self::parent(i); + // 노드를 조정할 필요가 없으면 힙 재정렬을 마친다 + if self.max_heap[i] <= self.max_heap[p] { + break; + } + // 두 노드를 교환한다 + self.swap(i, p); + // 위쪽으로 힙 재정렬을 반복한다 + i = p; + } + } + ``` + +=== "C" + + ```c title="my_heap.c" + /* 힙에 원소 삽입 */ + void push(MaxHeap *maxHeap, int val) { + // 기본적으로 이렇게 많은 노드를 추가해서는 안 된다 + if (maxHeap->size == MAX_SIZE) { + printf("힙이 가득 찼다!"); + return; + } + // 노드 추가 + maxHeap->data[maxHeap->size] = val; + maxHeap->size++; + + // 아래에서 위로 힙을 재정렬한다 + siftUp(maxHeap, maxHeap->size - 1); + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + void siftUp(MaxHeap *maxHeap, int i) { + while (true) { + // 노드 i의 부모 노드를 구한다 + int p = parent(maxHeap, i); + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if (p < 0 || maxHeap->data[i] <= maxHeap->data[p]) { + break; + } + // 두 노드를 교환한다 + swap(maxHeap, i, p); + // 위쪽으로 힙 재정렬을 반복한다 + i = p; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="my_heap.kt" + /* 힙에 원소 삽입 */ + fun push(_val: Int) { + // 노드 추가 + maxHeap.add(_val) + // 아래에서 위로 힙을 재정렬한다 + siftUp(size() - 1) + } + + /* 노드 i에서 시작하여 아래에서 위로 힙 재정렬 */ + fun siftUp(it: Int) { + // Kotlin의 함수 매개변수는 변경할 수 없으므로 임시 변수를 생성한다 + var i = it + while (true) { + // 노드 i의 부모 노드를 구한다 + val p = parent(i) + // 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + if (p < 0 || maxHeap[i] <= maxHeap[p]) break + // 두 노드를 교환한다 + swap(i, p) + // 위쪽으로 힙 재정렬을 반복한다 + i = p + } + } + ``` + +=== "Ruby" + + ```ruby title="my_heap.rb" + ### 힙에 원소 삽입 ### + def push(val) + # 노드 추가 + @max_heap << val + # 아래에서 위로 힙을 재정렬한다 + sift_up(size - 1) + end + + ### 노드 i에서 시작하여 아래에서 위로 힙 재정렬 ### + def sift_up(i) + loop do + # 노드 i의 부모 노드를 구한다 + p = parent(i) + # 루트 노드를 넘어가거나 노드를 수정할 필요가 없으면 힙 재정렬을 마친다 + break if p < 0 || @max_heap[i] <= @max_heap[p] + # 두 노드를 교환한다 + swap(i, p) + # 위쪽으로 힙 재정렬을 반복한다 + i = p + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 4.   힙의 루트 원소 꺼내기 + +힙의 루트 원소는 이진 트리의 루트 노드, 즉 리스트의 첫 번째 원소다. 리스트에서 첫 번째 원소를 바로 삭제하면 이진 트리의 모든 노드 인덱스가 바뀌어, 이후 힙 재정렬로 복구하기가 어려워진다. 원소 인덱스의 변화를 최소화하기 위해 다음 절차를 사용한다. + +1. 힙의 루트 원소와 끝 원소를 교환한다(루트 노드와 맨 오른쪽 리프 노드를 교환한다). +2. 교환 후 리스트에서 힙의 끝 원소를 삭제한다(이미 교환했으므로 실제로 삭제되는 것은 원래의 루트 원소다). +3. 루트 노드부터 **위에서 아래로 힙을 재정렬**한다. + +아래 그림(8-4)과 같이 **'위에서 아래로 힙 재정렬'은 '아래에서 위로 힙 재정렬'과 진행 방향이 반대다**. 루트 노드의 값을 두 자식 노드의 값과 비교하여, 더 큰 값을 가진 자식 노드와 루트 노드를 교환한다. 이 작업을 반복하며 리프 노드를 지나거나 교환할 필요가 없는 노드를 만나면 종료한다. + +=== "<1>" + ![힙의 루트 원소를 꺼내는 단계](heap.assets/heap_pop_step1.png){ class="animation-figure" } + +=== "<2>" + ![힙에서 꺼내기 2단계](heap.assets/heap_pop_step2.png){ class="animation-figure" } + +=== "<3>" + ![힙에서 꺼내기 3단계](heap.assets/heap_pop_step3.png){ class="animation-figure" } + +=== "<4>" + ![힙에서 꺼내기 4단계](heap.assets/heap_pop_step4.png){ class="animation-figure" } + +=== "<5>" + ![힙에서 꺼내기 5단계](heap.assets/heap_pop_step5.png){ class="animation-figure" } + +=== "<6>" + ![힙에서 꺼내기 6단계](heap.assets/heap_pop_step6.png){ class="animation-figure" } + +=== "<7>" + ![힙에서 꺼내기 7단계](heap.assets/heap_pop_step7.png){ class="animation-figure" } + +=== "<8>" + ![힙에서 꺼내기 8단계](heap.assets/heap_pop_step8.png){ class="animation-figure" } + +=== "<9>" + ![힙에서 꺼내기 9단계](heap.assets/heap_pop_step9.png){ class="animation-figure" } + +=== "<10>" + ![힙에서 꺼내기 10단계](heap.assets/heap_pop_step10.png){ class="animation-figure" } + +

그림 8-4   힙의 루트 원소를 꺼내는 단계

+ +원소 삽입과 마찬가지로 힙의 루트 원소를 꺼내는 연산의 시간 복잡도도 $O(\log n)$이다. 코드는 다음과 같다. + +=== "Python" + + ```python title="my_heap.py" + def pop(self) -> int: + """힙에서 원소 꺼내기""" + # 비어 있는 경우를 처리한다 + if self.is_empty(): + raise IndexError("힙이 비어 있다") + # 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + self.swap(0, self.size() - 1) + # 노드 삭제 + val = self.max_heap.pop() + # 위에서 아래로 힙을 재정렬한다 + self.sift_down(0) + # 힙의 루트 원소를 반환한다 + return val + + def sift_down(self, i: int): + """노드 i에서 시작하여 위에서 아래로 힙 재정렬""" + while True: + # 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + l, r, ma = self.left(i), self.right(i), i + if l < self.size() and self.max_heap[l] > self.max_heap[ma]: + ma = l + if r < self.size() and self.max_heap[r] > self.max_heap[ma]: + ma = r + # 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if ma == i: + break + # 두 노드를 교환한다 + self.swap(i, ma) + # 아래쪽으로 힙 재정렬을 반복한다 + i = ma + ``` + +=== "C++" + + ```cpp title="my_heap.cpp" + /* 힙에서 원소 꺼내기 */ + void pop() { + // 비어 있는 경우를 처리한다 + if (isEmpty()) { + throw out_of_range("힙이 비어 있다"); + } + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + swap(maxHeap[0], maxHeap[size() - 1]); + // 노드 삭제 + maxHeap.pop_back(); + // 위에서 아래로 힙을 재정렬한다 + siftDown(0); + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + void siftDown(int i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + int l = left(i), r = right(i), ma = i; + if (l < size() && maxHeap[l] > maxHeap[ma]) + ma = l; + if (r < size() && maxHeap[r] > maxHeap[ma]) + ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) + break; + swap(maxHeap[i], maxHeap[ma]); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + ``` + +=== "Java" + + ```java title="my_heap.java" + /* 힙에서 원소 꺼내기 */ + int pop() { + // 비어 있는 경우를 처리한다 + if (isEmpty()) + throw new IndexOutOfBoundsException(); + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + swap(0, size() - 1); + // 노드 삭제 + int val = maxHeap.remove(size() - 1); + // 위에서 아래로 힙을 재정렬한다 + siftDown(0); + // 힙의 루트 원소를 반환한다 + return val; + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + void siftDown(int i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + int l = left(i), r = right(i), ma = i; + if (l < size() && maxHeap.get(l) > maxHeap.get(ma)) + ma = l; + if (r < size() && maxHeap.get(r) > maxHeap.get(ma)) + ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) + break; + // 두 노드를 교환한다 + swap(i, ma); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + ``` + +=== "C#" + + ```csharp title="my_heap.cs" + /* 힙에서 원소 꺼내기 */ + int Pop() { + // 비어 있는 경우를 처리한다 + if (IsEmpty()) + throw new IndexOutOfRangeException(); + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + Swap(0, Size() - 1); + // 노드 삭제 + int val = maxHeap.Last(); + maxHeap.RemoveAt(Size() - 1); + // 위에서 아래로 힙을 재정렬한다 + SiftDown(0); + // 힙의 루트 원소를 반환한다 + return val; + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + void SiftDown(int i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + int l = Left(i), r = Right(i), ma = i; + if (l < Size() && maxHeap[l] > maxHeap[ma]) + ma = l; + if (r < Size() && maxHeap[r] > maxHeap[ma]) + ma = r; + // 노드 i의 값이 가장 크거나 리프 노드를 넘어가면 힙 재정렬을 종료한다 + if (ma == i) break; + // 두 노드를 교환한다 + Swap(i, ma); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + ``` + +=== "Go" + + ```go title="my_heap.go" + /* 힙에서 원소 꺼내기 */ + func (h *maxHeap) pop() any { + // 비어 있는 경우를 처리한다 + if h.isEmpty() { + fmt.Println("오류") + return nil + } + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + h.swap(0, h.size()-1) + // 노드 삭제 + val := h.data[len(h.data)-1] + h.data = h.data[:len(h.data)-1] + // 위에서 아래로 힙을 재정렬한다 + h.siftDown(0) + + // 힙의 루트 원소를 반환한다 + return val + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + func (h *maxHeap) siftDown(i int) { + for true { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 max로 표시한다 + l, r, max := h.left(i), h.right(i), i + if l < h.size() && h.data[l].(int) > h.data[max].(int) { + max = l + } + if r < h.size() && h.data[r].(int) > h.data[max].(int) { + max = r + } + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if max == i { + break + } + // 두 노드를 교환한다 + h.swap(i, max) + // 아래쪽으로 힙 재정렬을 반복한다 + i = max + } + } + ``` + +=== "Swift" + + ```swift title="my_heap.swift" + /* 힙에서 원소 꺼내기 */ + func pop() -> Int { + // 비어 있는 경우를 처리한다 + if isEmpty() { + fatalError("힙이 비어 있다") + } + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + swap(i: 0, j: size() - 1) + // 노드 삭제 + let val = maxHeap.remove(at: size() - 1) + // 위에서 아래로 힙을 재정렬한다 + siftDown(i: 0) + // 힙의 루트 원소를 반환한다 + return val + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + func siftDown(i: Int) { + var i = i + while true { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + let l = left(i: i) + let r = right(i: i) + var ma = i + if l < size(), maxHeap[l] > maxHeap[ma] { + ma = l + } + if r < size(), maxHeap[r] > maxHeap[ma] { + ma = r + } + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if ma == i { + break + } + // 두 노드를 교환한다 + swap(i: i, j: ma) + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma + } + } + ``` + +=== "JS" + + ```javascript title="my_heap.js" + /* 힙에서 원소 꺼내기 */ + pop() { + // 비어 있는 경우를 처리한다 + if (this.isEmpty()) throw new Error('힙이 비어 있다'); + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + this.#swap(0, this.size() - 1); + // 노드 삭제 + const val = this.#maxHeap.pop(); + // 위에서 아래로 힙을 재정렬한다 + this.#siftDown(0); + // 힙의 루트 원소를 반환한다 + return val; + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + #siftDown(i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + const l = this.#left(i), + r = this.#right(i); + let ma = i; + if (l < this.size() && this.#maxHeap[l] > this.#maxHeap[ma]) ma = l; + if (r < this.size() && this.#maxHeap[r] > this.#maxHeap[ma]) ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma === i) break; + // 두 노드를 교환한다 + this.#swap(i, ma); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + ``` + +=== "TS" + + ```typescript title="my_heap.ts" + /* 힙에서 원소 꺼내기 */ + pop(): number { + // 비어 있는 경우를 처리한다 + if (this.isEmpty()) throw new RangeError('힙이 비어 있다.'); + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + this.swap(0, this.size() - 1); + // 노드 삭제 + const val = this.maxHeap.pop(); + // 위에서 아래로 힙을 재정렬한다 + this.siftDown(0); + // 힙의 루트 원소를 반환한다 + return val; + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + siftDown(i: number): void { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + const l = this.left(i), + r = this.right(i); + let ma = i; + if (l < this.size() && this.maxHeap[l] > this.maxHeap[ma]) ma = l; + if (r < this.size() && this.maxHeap[r] > this.maxHeap[ma]) ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma === i) break; + // 두 노드를 교환한다 + this.swap(i, ma); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + ``` + +=== "Dart" + + ```dart title="my_heap.dart" + /* 힙에서 원소 꺼내기 */ + int pop() { + // 비어 있는 경우를 처리한다 + if (isEmpty()) throw Exception('힙이 비어 있다'); + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + _swap(0, size() - 1); + // 노드 삭제 + int val = _maxHeap.removeLast(); + // 위에서 아래로 힙을 재정렬한다 + siftDown(0); + // 힙의 루트 원소를 반환한다 + return val; + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + void siftDown(int i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + int l = _left(i); + int r = _right(i); + int ma = i; + if (l < size() && _maxHeap[l] > _maxHeap[ma]) ma = l; + if (r < size() && _maxHeap[r] > _maxHeap[ma]) ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) break; + // 두 노드를 교환한다 + _swap(i, ma); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + ``` + +=== "Rust" + + ```rust title="my_heap.rs" + /* 힙에서 원소 꺼내기 */ + fn pop(&mut self) -> i32 { + // 비어 있는 경우를 처리한다 + if self.is_empty() { + panic!("인덱스가 범위를 벗어났다"); + } + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + self.swap(0, self.size() - 1); + // 노드 삭제 + let val = self.max_heap.pop().unwrap(); + // 위에서 아래로 힙을 재정렬한다 + self.sift_down(0); + // 힙의 루트 원소를 반환한다 + val + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + fn sift_down(&mut self, mut i: usize) { + loop { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + let (l, r, mut ma) = (Self::left(i), Self::right(i), i); + if l < self.size() && self.max_heap[l] > self.max_heap[ma] { + ma = l; + } + if r < self.size() && self.max_heap[r] > self.max_heap[ma] { + ma = r; + } + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if ma == i { + break; + } + // 두 노드를 교환한다 + self.swap(i, ma); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + ``` + +=== "C" + + ```c title="my_heap.c" + /* 힙에서 원소 꺼내기 */ + int pop(MaxHeap *maxHeap) { + // 비어 있는 경우를 처리한다 + if (isEmpty(maxHeap)) { + printf("힙이 비어 있다!"); + return INT_MAX; + } + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + swap(maxHeap, 0, size(maxHeap) - 1); + // 노드 삭제 + int val = maxHeap->data[maxHeap->size - 1]; + maxHeap->size--; + // 위에서 아래로 힙을 재정렬한다 + siftDown(maxHeap, 0); + + // 힙의 루트 원소를 반환한다 + return val; + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + void siftDown(MaxHeap *maxHeap, int i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 max로 표시한다 + int l = left(maxHeap, i); + int r = right(maxHeap, i); + int max = i; + if (l < size(maxHeap) && maxHeap->data[l] > maxHeap->data[max]) { + max = l; + } + if (r < size(maxHeap) && maxHeap->data[r] > maxHeap->data[max]) { + max = r; + } + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (max == i) { + break; + } + // 두 노드를 교환한다 + swap(maxHeap, i, max); + // 아래쪽으로 힙 재정렬을 반복한다 + i = max; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="my_heap.kt" + /* 힙에서 원소 꺼내기 */ + fun pop(): Int { + // 비어 있는 경우를 처리한다 + if (isEmpty()) throw IndexOutOfBoundsException() + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + swap(0, size() - 1) + // 노드 삭제 + val _val = maxHeap.removeAt(size() - 1) + // 위에서 아래로 힙을 재정렬한다 + siftDown(0) + // 힙의 루트 원소를 반환한다 + return _val + } + + /* 노드 i에서 시작하여 위에서 아래로 힙 재정렬 */ + fun siftDown(it: Int) { + // Kotlin의 함수 매개변수는 변경할 수 없으므로 임시 변수를 생성한다 + var i = it + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + val l = left(i) + val r = right(i) + var ma = i + if (l < size() && maxHeap[l] > maxHeap[ma]) ma = l + if (r < size() && maxHeap[r] > maxHeap[ma]) ma = r + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) break + // 두 노드를 교환한다 + swap(i, ma) + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma + } + } + ``` + +=== "Ruby" + + ```ruby title="my_heap.rb" + ### 힙에서 원소 꺼내기 ### + def pop + # 비어 있는 경우를 처리한다 + raise IndexError, "힙이 비어 있다" if is_empty? + # 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + swap(0, size - 1) + # 노드 삭제 + val = @max_heap.pop + # 위에서 아래로 힙을 재정렬한다 + sift_down(0) + # 힙의 루트 원소를 반환한다 + val + end + + ### 노드 i에서 시작하여 위에서 아래로 힙 재정렬 ### + def sift_down(i) + loop do + # 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + l, r, ma = left(i), right(i), i + ma = l if l < size && @max_heap[l] > @max_heap[ma] + ma = r if r < size && @max_heap[r] > @max_heap[ma] + + # 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + break if ma == i + + # 두 노드를 교환한다 + swap(i, ma) + # 아래쪽으로 힙 재정렬을 반복한다 + i = ma + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 8.1.3   힙의 주요 응용 + +- **우선순위 큐**: 힙은 우선순위 큐를 구현할 때 가장 먼저 고려하는 자료 구조다. 삽입과 꺼내기 연산의 시간 복잡도는 모두 $O(\log n)$이고, 힙 구성 연산은 $O(n)$으로 모두 매우 효율적이다. +- **힙 정렬**: 데이터가 주어지면 이를 이용해 힙을 만든 뒤 원소를 계속 꺼내어 정렬된 데이터를 얻을 수 있다. 다만 힙 정렬은 보통 더 간결한 방법으로 구현한다. 자세한 내용은 '힙 정렬' 절을 참고하자. +- **가장 큰 $k$ 개 원소 구하기**: 대표적인 알고리즘 문제이자 흔한 응용 사례다. 예를 들어 인기 상위 10개 뉴스를 웨이보의 인기 검색어로 선정하거나, 판매량 상위 10개 상품을 고를 때 사용할 수 있다. diff --git a/ko/docs/chapter_heap/index.md b/ko/docs/chapter_heap/index.md new file mode 100644 index 000000000..ff56875ce --- /dev/null +++ b/ko/docs/chapter_heap/index.md @@ -0,0 +1,22 @@ +--- +comments: true +icon: material/family-tree +--- + +# 제 8 장   힙 + +![힙](../assets/covers/chapter_heap.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 힙은 겹겹이 이어진 산봉우리처럼 높낮이도 모습도 제각각이다. + + 봉우리마다 높이가 다르지만, 가장 높은 봉우리는 언제나 가장 먼저 눈에 들어온다. + +## 이 장의 내용 + +- [8.1   힙](heap.md) +- [8.2   힙 구성 연산](build_heap.md) +- [8.3   Top-k 문제](top_k.md) +- [8.4   요약](summary.md) +- [8.5   연습 문제](exercises.md) diff --git a/ko/docs/chapter_heap/summary.md b/ko/docs/chapter_heap/summary.md new file mode 100644 index 000000000..2fc75d66b --- /dev/null +++ b/ko/docs/chapter_heap/summary.md @@ -0,0 +1,21 @@ +--- +comments: true +--- + +# 8.4   요약 + +### 1.   핵심 정리 + +- 힙은 완전 이진 트리이며, 만족하는 조건에 따라 최대 힙과 최소 힙으로 나뉜다. 최대(최소) 힙의 루트 원소는 가장 큰(작은) 값이다. +- 우선순위 큐는 원소를 꺼내는 우선순위가 있는 큐로 정의되며, 보통 힙으로 구현한다. +- 힙의 주요 연산과 시간 복잡도는 원소 삽입 $O(\log n)$, 루트 원소 꺼내기 $O(\log n)$, 루트 원소 접근 $O(1)$ 등이다. +- 완전 이진 트리는 배열로 표현하기에 매우 적합하므로, 힙은 보통 배열에 저장한다. +- 힙 재정렬 연산은 힙의 성질을 유지하는 데 사용하며, 삽입과 꺼내기 연산 모두에서 필요하다. +- $n$ 개의 원소를 입력받아 힙을 구성하는 시간 복잡도는 $O(n)$까지 최적화할 수 있어 매우 효율적이다. +- Top-k는 대표적인 알고리즘 문제다. 힙 자료 구조를 사용하면 $O(n \log k)$의 시간 복잡도로 효율적으로 해결할 수 있다. + +### 2.   Q & A + +**Q**: 자료 구조의 '힙'과 메모리 관리의 '힙'은 같은 개념인가? + +두 개념은 서로 다르며, 우연히 이름만 '힙'으로 같을 뿐이다. 컴퓨터 시스템 메모리의 힙은 동적 메모리 할당에 사용되는 영역으로, 프로그램이 실행 중에 데이터를 저장하는 데 사용할 수 있다. 프로그램은 객체나 배열 같은 복잡한 구조를 저장하기 위해 일정량의 힙 메모리를 요청할 수 있다. 데이터가 더 이상 필요하지 않으면 메모리 누수를 막기 위해 해당 메모리를 해제해야 한다. 스택 메모리에 비해 힙 메모리는 관리와 사용에 더 주의해야 하며, 잘못 사용하면 메모리 누수나 유효하지 않은 포인터 등의 문제가 발생할 수 있다. diff --git a/ko/docs/chapter_heap/top_k.md b/ko/docs/chapter_heap/top_k.md new file mode 100644 index 000000000..f3852c696 --- /dev/null +++ b/ko/docs/chapter_heap/top_k.md @@ -0,0 +1,471 @@ +--- +comments: true +--- + +# 8.3   Top-k 문제 + +!!! question "문제" + + 길이가 $n$인 정렬되지 않은 배열 `nums`가 주어졌을 때, 배열에서 가장 큰 $k$ 개의 원소를 반환하라. + +이 문제에 대해 먼저 발상이 비교적 직접적인 두 가지 풀이를 살펴보고, 이어서 더 효율적인 힙 기반 풀이를 소개한다. + +## 8.3.1   방법 1: 순회하며 선택 + +아래 그림(8-6)과 같이 $k$ 번 순회하면서 매 순회에서 각각 $1$, $2$, $\dots$, $k$ 번째로 큰 원소를 뽑을 수 있다. 시간 복잡도는 $O(nk)$이다. + +이 방법은 $k \ll n$인 경우에만 적합하다. $k$가 $n$에 가까워지면 시간 복잡도가 $O(n^2)$에 가까워져 매우 오래 걸리기 때문이다. + +![순회로 가장 큰 k개의 원소 찾기](top_k.assets/top_k_traversal.png){ class="animation-figure" } + +

그림 8-6   순회로 가장 큰 k개의 원소 찾기

+ +!!! tip "참고" + + $k = n$이면 전체가 정렬된 수열을 얻을 수 있으며, 이는 '선택 정렬' 알고리즘과 같다. + +## 8.3.2   방법 2: 정렬 + +아래 그림(8-7)과 같이 배열 `nums`를 먼저 정렬한 뒤 맨 오른쪽의 $k$ 개 원소를 반환할 수 있다. 시간 복잡도는 $O(n \log n)$이다. + +이 방법은 필요한 것보다 더 많은 일을 한다. 가장 큰 $k$ 개 원소만 찾으면 되며, 나머지 원소를 정렬할 필요는 없기 때문이다. + +![정렬로 가장 큰 k개의 원소 찾기](top_k.assets/top_k_sorting.png){ class="animation-figure" } + +

그림 8-7   정렬로 가장 큰 k개의 원소 찾기

+ +## 8.3.3   방법 3: 힙 + +힙을 이용하면 Top-k 문제를 더 효율적으로 해결할 수 있다. 과정은 아래 그림(8-8)과 같다. + +1. 루트 원소가 가장 작은 최소 힙을 초기화한다. +2. 먼저 배열의 앞 $k$ 개 원소를 차례로 힙에 삽입한다. +3. $k + 1$ 번째 원소부터 시작하여, 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 삽입한다. +4. 순회를 마치면 힙에 남은 원소가 가장 큰 $k$ 개 원소다. + +=== "<1>" + ![힙으로 가장 큰 k개의 원소 찾기](top_k.assets/top_k_heap_step1.png){ class="animation-figure" } + +=== "<2>" + ![힙으로 Top-k 찾기 2단계](top_k.assets/top_k_heap_step2.png){ class="animation-figure" } + +=== "<3>" + ![힙으로 Top-k 찾기 3단계](top_k.assets/top_k_heap_step3.png){ class="animation-figure" } + +=== "<4>" + ![힙으로 Top-k 찾기 4단계](top_k.assets/top_k_heap_step4.png){ class="animation-figure" } + +=== "<5>" + ![힙으로 Top-k 찾기 5단계](top_k.assets/top_k_heap_step5.png){ class="animation-figure" } + +=== "<6>" + ![힙으로 Top-k 찾기 6단계](top_k.assets/top_k_heap_step6.png){ class="animation-figure" } + +=== "<7>" + ![힙으로 Top-k 찾기 7단계](top_k.assets/top_k_heap_step7.png){ class="animation-figure" } + +=== "<8>" + ![힙으로 Top-k 찾기 8단계](top_k.assets/top_k_heap_step8.png){ class="animation-figure" } + +=== "<9>" + ![힙으로 Top-k 찾기 9단계](top_k.assets/top_k_heap_step9.png){ class="animation-figure" } + +

그림 8-8   힙으로 가장 큰 k개의 원소 찾기

+ +예시 코드는 다음과 같다. + +=== "Python" + + ```python title="top_k.py" + def top_k_heap(nums: list[int], k: int) -> list[int]: + """힙을 이용하여 배열에서 가장 큰 k개 원소 탐색""" + # 최소 힙 초기화 + heap = [] + # 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for i in range(k): + heapq.heappush(heap, nums[i]) + # k+1번째 원소부터 힙의 길이를 k로 유지한다 + for i in range(k, len(nums)): + # 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if nums[i] > heap[0]: + heapq.heappop(heap) + heapq.heappush(heap, nums[i]) + return heap + ``` + +=== "C++" + + ```cpp title="top_k.cpp" + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + priority_queue, greater> topKHeap(vector &nums, int k) { + // 최소 힙 초기화 + priority_queue, greater> heap; + // 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for (int i = 0; i < k; i++) { + heap.push(nums[i]); + } + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for (int i = k; i < nums.size(); i++) { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if (nums[i] > heap.top()) { + heap.pop(); + heap.push(nums[i]); + } + } + return heap; + } + ``` + +=== "Java" + + ```java title="top_k.java" + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + Queue topKHeap(int[] nums, int k) { + // 최소 힙 초기화 + Queue heap = new PriorityQueue(); + // 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for (int i = 0; i < k; i++) { + heap.offer(nums[i]); + } + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for (int i = k; i < nums.length; i++) { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if (nums[i] > heap.peek()) { + heap.poll(); + heap.offer(nums[i]); + } + } + return heap; + } + ``` + +=== "C#" + + ```csharp title="top_k.cs" + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + PriorityQueue TopKHeap(int[] nums, int k) { + // 최소 힙 초기화 + PriorityQueue heap = new(); + // 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for (int i = 0; i < k; i++) { + heap.Enqueue(nums[i], nums[i]); + } + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for (int i = k; i < nums.Length; i++) { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if (nums[i] > heap.Peek()) { + heap.Dequeue(); + heap.Enqueue(nums[i], nums[i]); + } + } + return heap; + } + ``` + +=== "Go" + + ```go title="top_k.go" + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + func topKHeap(nums []int, k int) *minHeap { + // 최소 힙 초기화 + h := &minHeap{} + heap.Init(h) + // 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for i := 0; i < k; i++ { + heap.Push(h, nums[i]) + } + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for i := k; i < len(nums); i++ { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if nums[i] > h.Top().(int) { + heap.Pop(h) + heap.Push(h, nums[i]) + } + } + return h + } + ``` + +=== "Swift" + + ```swift title="top_k.swift" + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + func topKHeap(nums: [Int], k: Int) -> [Int] { + // 최소 힙을 초기화하여 앞쪽 k개 원소로 힙을 구성한다 + var heap = Heap(nums.prefix(k)) + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for i in nums.indices.dropFirst(k) { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if nums[i] > heap.min()! { + _ = heap.removeMin() + heap.insert(nums[i]) + } + } + return heap.unordered + } + ``` + +=== "JS" + + ```javascript title="top_k.js" + /* 힙에 원소 삽입 */ + function pushMinHeap(maxHeap, val) { + // 원소의 부호를 반전한다 + maxHeap.push(-val); + } + + /* 힙에서 원소 꺼내기 */ + function popMinHeap(maxHeap) { + // 원소의 부호를 반전한다 + return -maxHeap.pop(); + } + + /* 힙의 루트 원소 접근 */ + function peekMinHeap(maxHeap) { + // 원소의 부호를 반전한다 + return -maxHeap.peek(); + } + + /* 힙의 원소를 반환한다 */ + function getMinHeap(maxHeap) { + // 원소의 부호를 반전한다 + return maxHeap.getMaxHeap().map((num) => -num); + } + + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + function topKHeap(nums, k) { + // 최소 힙 초기화 + // 힙의 모든 원소의 부호를 반전하여 최대 힙으로 최소 힙을 구현한다는 점에 유의한다 + const maxHeap = new MaxHeap([]); + // 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for (let i = 0; i < k; i++) { + pushMinHeap(maxHeap, nums[i]); + } + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for (let i = k; i < nums.length; i++) { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if (nums[i] > peekMinHeap(maxHeap)) { + popMinHeap(maxHeap); + pushMinHeap(maxHeap, nums[i]); + } + } + // 힙의 원소를 반환한다 + return getMinHeap(maxHeap); + } + ``` + +=== "TS" + + ```typescript title="top_k.ts" + /* 힙에 원소 삽입 */ + function pushMinHeap(maxHeap: MaxHeap, val: number): void { + // 원소의 부호를 반전한다 + maxHeap.push(-val); + } + + /* 힙에서 원소 꺼내기 */ + function popMinHeap(maxHeap: MaxHeap): number { + // 원소의 부호를 반전한다 + return -maxHeap.pop(); + } + + /* 힙의 루트 원소 접근 */ + function peekMinHeap(maxHeap: MaxHeap): number { + // 원소의 부호를 반전한다 + return -maxHeap.peek(); + } + + /* 힙의 원소를 반환한다 */ + function getMinHeap(maxHeap: MaxHeap): number[] { + // 원소의 부호를 반전한다 + return maxHeap.getMaxHeap().map((num: number) => -num); + } + + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + function topKHeap(nums: number[], k: number): number[] { + // 최소 힙 초기화 + // 힙의 모든 원소의 부호를 반전하여 최대 힙으로 최소 힙을 구현한다는 점에 유의한다 + const maxHeap = new MaxHeap([]); + // 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for (let i = 0; i < k; i++) { + pushMinHeap(maxHeap, nums[i]); + } + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for (let i = k; i < nums.length; i++) { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if (nums[i] > peekMinHeap(maxHeap)) { + popMinHeap(maxHeap); + pushMinHeap(maxHeap, nums[i]); + } + } + // 힙의 원소를 반환한다 + return getMinHeap(maxHeap); + } + ``` + +=== "Dart" + + ```dart title="top_k.dart" + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + MinHeap topKHeap(List nums, int k) { + // 최소 힙을 초기화하고 배열의 앞쪽 k개 원소를 힙에 삽입한다 + MinHeap heap = MinHeap(nums.sublist(0, k)); + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for (int i = k; i < nums.length; i++) { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if (nums[i] > heap.peek()) { + heap.pop(); + heap.push(nums[i]); + } + } + return heap; + } + ``` + +=== "Rust" + + ```rust title="top_k.rs" + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + fn top_k_heap(nums: Vec, k: usize) -> BinaryHeap> { + // BinaryHeap은 최대 힙이므로 Reverse로 원소의 순서를 뒤집어 최소 힙을 구현한다 + let mut heap = BinaryHeap::>::new(); + // 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for &num in nums.iter().take(k) { + heap.push(Reverse(num)); + } + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for &num in nums.iter().skip(k) { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if num > heap.peek().unwrap().0 { + heap.pop(); + heap.push(Reverse(num)); + } + } + heap + } + ``` + +=== "C" + + ```c title="top_k.c" + /* 힙에 원소 삽입 */ + void pushMinHeap(MaxHeap *maxHeap, int val) { + // 원소의 부호를 반전한다 + push(maxHeap, -val); + } + + /* 힙에서 원소 꺼내기 */ + int popMinHeap(MaxHeap *maxHeap) { + // 원소의 부호를 반전한다 + return -pop(maxHeap); + } + + /* 힙의 루트 원소 접근 */ + int peekMinHeap(MaxHeap *maxHeap) { + // 원소의 부호를 반전한다 + return -peek(maxHeap); + } + + /* 힙의 원소를 반환한다 */ + int *getMinHeap(MaxHeap *maxHeap) { + // 힙의 모든 원소의 부호를 반전하여 res 배열에 저장한다 + int *res = (int *)malloc(maxHeap->size * sizeof(int)); + for (int i = 0; i < maxHeap->size; i++) { + res[i] = -maxHeap->data[i]; + } + return res; + } + + /* 힙의 원소를 반환한다 */ + int *getMinHeap(MaxHeap *maxHeap) { + // 힙의 모든 원소의 부호를 반전하여 res 배열에 저장한다 + int *res = (int *)malloc(maxHeap->size * sizeof(int)); + for (int i = 0; i < maxHeap->size; i++) { + res[i] = -maxHeap->data[i]; + } + return res; + } + + // 힙으로 배열에서 가장 큰 k개 원소를 찾는 함수 + int *topKHeap(int *nums, int sizeNums, int k) { + // 최소 힙 초기화 + // 힙의 모든 원소의 부호를 반전하여 최대 힙으로 최소 힙을 구현한다는 점에 유의한다 + int *empty = (int *)malloc(0); + MaxHeap *maxHeap = newMaxHeap(empty, 0); + // 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for (int i = 0; i < k; i++) { + pushMinHeap(maxHeap, nums[i]); + } + // k+1번째 원소부터 힙의 길이를 k로 유지한다 + for (int i = k; i < sizeNums; i++) { + // 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if (nums[i] > peekMinHeap(maxHeap)) { + popMinHeap(maxHeap); + pushMinHeap(maxHeap, nums[i]); + } + } + int *res = getMinHeap(maxHeap); + // 메모리 해제 + delMaxHeap(maxHeap); + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="top_k.kt" + /* 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 */ + fun topKHeap(nums: IntArray, k: Int): Queue { + // 최소 힙 초기화 + val heap = PriorityQueue() + // 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for (i in 0.. heap.peek()) { + heap.poll() + heap.offer(nums[i]) + } + } + return heap + } + ``` + +=== "Ruby" + + ```ruby title="top_k.rb" + ### 힙을 이용하여 배열에서 가장 큰 k개 원소 탐색 ### + def top_k_heap(nums, k) + # 최소 힙 초기화 + # 힙의 모든 원소의 부호를 반전하여 최대 힙으로 최소 힙을 구현한다는 점에 유의한다 + max_heap = MaxHeap.new([]) + + # 배열의 앞쪽 k개 원소를 힙에 삽입한다 + for i in 0...k + push_min_heap(max_heap, nums[i]) + end + + # k+1번째 원소부터 힙의 길이를 k로 유지한다 + for i in k...nums.length + # 현재 원소가 힙의 루트 원소보다 크면 루트 원소를 꺼내고 현재 원소를 힙에 삽입한다 + if nums[i] > peek_min_heap(max_heap) + pop_min_heap(max_heap) + push_min_heap(max_heap, nums[i]) + end + end + + get_min_heap(max_heap) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +삽입과 꺼내기를 총 $n$ 차례 수행하고 힙의 최대 길이는 $k$이므로, 시간 복잡도는 $O(n \log k)$이다. 이 방법은 매우 효율적이다. $k$가 작으면 시간 복잡도가 $O(n)$에 가까워지고, $k$가 커도 $O(n \log n)$을 넘지 않는다. + +또한 이 방법은 동적으로 들어오는 데이터 스트림에도 적합하다. 데이터가 계속 추가될 때 힙 안의 원소를 지속적으로 관리하면 가장 큰 $k$ 개 원소를 동적으로 갱신할 수 있다. diff --git a/ko/docs/chapter_hello_algo/index.md b/ko/docs/chapter_hello_algo/index.md new file mode 100644 index 000000000..c82d6ed19 --- /dev/null +++ b/ko/docs/chapter_hello_algo/index.md @@ -0,0 +1,30 @@ +--- +comments: true +icon: material/rocket-launch-outline +--- + +# 머리말 + +몇 년 전, 나는 리트코드에 『剑指 Offer』의 문제 풀이 시리즈를 공유했고 많은 독자의 격려와 지지를 받았다. 독자들과 이야기하면서 가장 자주 받은 질문은 “알고리즘 공부를 어떻게 시작하면 좋을까요?”였다. 점차 나는 이 질문에 깊은 관심을 갖게 되었다. + +아무것도 모르는 상태에서 무작정 문제를 푸는 방식이 가장 인기 있는 방법인 듯하다. 간단하고 직접적이며 효과도 있다. 하지만 문제 풀이는 ‘지뢰 찾기’ 게임과 비슷하다. 스스로 공부하는 능력이 뛰어난 사람은 지뢰를 하나씩 무사히 제거하지만, 기초가 부족한 사람은 지뢰에 거듭 부딪혀 좌절하며 점점 물러서기 쉽다. 교재를 처음부터 끝까지 읽는 것도 흔한 방법이다. 그러나 취업을 준비하는 사람에게는 졸업 논문 작성, 이력서 제출, 필기시험과 면접 준비만으로도 대부분의 에너지가 소모되므로, 두꺼운 책을 파고드는 일이 큰 도전이 되곤 한다. + +비슷한 어려움을 겪고 있다면, 이 책이 여러분을 ‘찾아온’ 것은 다행스러운 일이다. 이 책은 그 질문에 대한 나의 답이다. 최적의 답은 아닐지라도, 적어도 적극적인 시도는 될 것이다. 이 책만으로 곧바로 취업에 성공하기는 어렵겠지만, 자료 구조와 알고리즘의 ‘지식 지도’를 탐험하도록 안내하고 여러 ‘지뢰’의 모양, 크기, 분포를 알려 주며 다양한 ‘지뢰 제거 방법’을 익히도록 도울 것이다. 이러한 능력을 갖추면 문제를 풀고 문헌을 읽는 일이 한결 수월해지고, 차츰 완전한 지식 체계를 세울 수 있을 것이다. + +나는 파인만 교수의 말에 깊이 공감한다. “지식은 공짜가 아니다. 주의를 기울여야 한다.” 이런 의미에서 이 책은 완전히 ‘무료’인 것은 아니다. 여러분이 이 책에 쏟는 소중한 ‘주의와 관심’을 헛되게 하지 않도록, 나 역시 최대한의 ‘주의와 관심’을 기울여 집필에 최선을 다하고자 한다. + +내 학식과 재능이 부족함을 잘 알고 있다. 책의 내용을 한동안 다듬어 왔지만 여전히 많은 오류가 있을 것이다. 선생님과 학생 여러분의 비판과 지적을 부탁드린다. + +![Hello 알고리즘](../assets/covers/chapter_hello_algo.jpg){ class="cover-image" } + +
+

Hello, 알고리즘!

+
+ +컴퓨터의 등장은 세상을 크게 바꾸었다. 빠른 계산 능력과 뛰어난 프로그래밍 가능성을 갖춘 컴퓨터는 알고리즘을 실행하고 데이터를 처리하는 이상적인 수단이 되었다. 비디오 게임의 사실적인 화면, 자율 주행의 지능적인 판단, AlphaGo의 멋진 대국, ChatGPT의 자연스러운 상호작용까지, 이 모든 응용은 컴퓨터 위에서 알고리즘이 정교하게 구현된 결과다. + +사실 컴퓨터가 등장하기 전부터 알고리즘과 자료 구조는 세상 곳곳에 존재했다. 초기의 알고리즘은 고대의 셈법이나 도구 제작 절차처럼 비교적 단순했다. 문명이 발전하면서 알고리즘도 점점 더 정교하고 복잡해졌다. 장인의 놀라운 솜씨에서 생산력을 높여 준 산업 제품에 이르기까지, 나아가 우주의 운행을 설명하는 과학 법칙에 이르기까지, 평범하거나 경이로운 거의 모든 것의 이면에는 정교한 알고리즘적 사고가 숨어 있다. + +자료 구조 역시 어디에나 있다. 크게는 사회 관계망부터 작게는 지하철 노선까지 많은 시스템을 ‘그래프’로 모델링할 수 있다. 국가부터 가정에 이르기까지 사회의 주요 조직 형태는 ‘트리’의 특성을 보인다. 겨울옷은 ‘스택’과 같아서 가장 먼저 입은 옷을 가장 나중에 벗는다. 셔틀콕 통은 한쪽으로 넣고 다른 쪽으로 꺼낸다는 점에서 ‘큐’와 닮았다. 사전은 원하는 표제어를 빠르게 찾을 수 있다는 점에서 ‘해시 테이블’과 비슷하다. + +이 책은 명확하고 이해하기 쉬운 애니메이션 그림과 실행 가능한 코드 예제를 통해 독자가 알고리즘과 자료 구조의 핵심 개념을 이해하고 직접 프로그래밍으로 구현할 수 있도록 돕는 것을 목표로 한다. 이를 바탕으로 복잡한 세상 속에서 알고리즘이 생생하게 드러나는 모습을 보여 주며 알고리즘의 아름다움을 전하고자 한다. 이 책이 여러분에게 도움이 되기를 바란다! diff --git a/ko/docs/chapter_introduction/algorithms_are_everywhere.md b/ko/docs/chapter_introduction/algorithms_are_everywhere.md new file mode 100644 index 000000000..01159a8b7 --- /dev/null +++ b/ko/docs/chapter_introduction/algorithms_are_everywhere.md @@ -0,0 +1,66 @@ +--- +comments: true +--- + +# 1.1   어디에나 있는 알고리즘 + +‘알고리즘’이라는 말을 들으면 자연스럽게 수학을 떠올리게 된다. 하지만 실제로 많은 알고리즘은 복잡한 수학보다는 기본적인 논리에 더 많이 의존하며, 이러한 논리는 일상생활 곳곳에서 찾아볼 수 있다. + +알고리즘을 본격적으로 살펴보기 전에 흥미로운 사실 하나를 소개하고자 한다. **여러분은 이미 자신도 모르게 많은 알고리즘을 익혔고, 일상생활에서 자연스럽게 사용하고 있다.** 몇 가지 구체적인 예를 통해 이 사실을 확인해 보자. + +**예 1: 사전에서 단어 찾기**. 중국어 사전에서는 각 한자에 대응하는 병음이 있고, 표제어는 병음의 알파벳순으로 배열되어 있다. 병음의 첫 글자가 $r$인 한자를 찾는다고 가정하면, 보통 아래 그림(1-1)과 같은 방법을 사용한다. + +1. 사전의 중간쯤을 펼쳐 그 페이지의 병음 첫 글자를 확인한다. 첫 글자가 $m$이라고 가정하자. +2. 병음의 알파벳순에서 $r$은 $m$ 뒤에 있으므로 사전의 앞쪽 절반을 제외하고 탐색 범위를 뒤쪽 절반으로 좁힌다. +3. 병음의 첫 글자가 $r$인 페이지를 찾을 때까지 단계 `1.`과 단계 `2.`를 반복한다. + +=== "<1>" + ![사전에서 단어를 찾는 단계](algorithms_are_everywhere.assets/binary_search_dictionary_step1.png){ class="animation-figure" } + +=== "<2>" + ![사전에서 단어 찾기: 2단계](algorithms_are_everywhere.assets/binary_search_dictionary_step2.png){ class="animation-figure" } + +=== "<3>" + ![사전에서 단어 찾기: 3단계](algorithms_are_everywhere.assets/binary_search_dictionary_step3.png){ class="animation-figure" } + +=== "<4>" + ![사전에서 단어 찾기: 4단계](algorithms_are_everywhere.assets/binary_search_dictionary_step4.png){ class="animation-figure" } + +=== "<5>" + ![사전에서 단어 찾기: 5단계](algorithms_are_everywhere.assets/binary_search_dictionary_step5.png){ class="animation-figure" } + +

그림 1-1   사전에서 단어를 찾는 단계

+ +초등학생도 익히는 사전 찾기는 사실 유명한 ‘이진 탐색’ 알고리즘이다. 자료 구조의 관점에서 사전은 정렬된 ‘배열’로 볼 수 있고, 알고리즘의 관점에서 앞서 살펴본 사전 찾기의 일련의 동작은 ‘이진 탐색’으로 볼 수 있다. + +**예 2: 카드 정리하기**. 카드 게임을 할 때는 매번 손에 든 카드를 작은 수부터 큰 수 순서로 정리한다. 그 과정은 아래 그림(1-2)과 같다. + +1. 카드를 ‘정렬된 부분’과 ‘정렬되지 않은 부분’으로 나누고, 처음에는 맨 왼쪽 카드 1장이 이미 정렬되어 있다고 가정한다. +2. 정렬되지 않은 부분에서 카드 한 장을 꺼내 정렬된 부분의 올바른 위치에 끼워 넣는다. 그러면 맨 왼쪽 카드 2장이 정렬된 상태가 된다. +3. 단계 `2.`를 반복하여 매번 정렬되지 않은 부분의 카드 한 장을 정렬된 부분에 끼워 넣는다. 모든 카드가 정렬될 때까지 계속한다. + +![카드를 정렬하는 단계](algorithms_are_everywhere.assets/playing_cards_sorting.png){ class="animation-figure" } + +

그림 1-2   카드를 정렬하는 단계

+ +이러한 카드 정리 방법은 본질적으로 ‘삽입 정렬’ 알고리즘이며, 작은 데이터 집합을 처리할 때 매우 효율적이다. 많은 프로그래밍 언어의 정렬 라이브러리 함수에서도 삽입 정렬이 사용된다. + +**예 3: 거스름돈 주기**. 마트에서 $69$위안어치 물건을 사고 계산원에게 $100$위안을 냈다고 가정하자. 계산원은 $31$위안을 거슬러 주어야 하며, 자연스럽게 아래 그림(1-3)과 같은 과정을 떠올린다. + +1. 선택할 수 있는 것은 액면가가 $31$위안보다 작은 화폐로, $1$위안, $5$위안, $10$위안, $20$위안이 있다. +2. 선택지 중 가장 큰 $20$위안을 꺼내면 $31 - 20 = 11$위안이 남는다. +3. 남은 선택지 중 가장 큰 $10$위안을 꺼내면 $11 - 10 = 1$위안이 남는다. +4. 남은 선택지 중 가장 큰 $1$위안을 꺼내면 $1 - 1 = 0$위안이 남는다. +5. 거스름돈을 모두 주었다. 그 구성은 $20 + 10 + 1 = 31$위안이다. + +![거스름돈을 주는 과정](algorithms_are_everywhere.assets/greedy_change.png){ class="animation-figure" } + +

그림 1-3   거스름돈을 주는 과정

+ +위 과정에서는 각 단계마다 현재 가장 좋아 보이는 선택, 즉 가능한 한 액면가가 큰 화폐를 고른다. 그 결과 거스름돈을 줄 수 있는 방법을 얻는다. 자료 구조와 알고리즘의 관점에서 이 방법은 본질적으로 ‘탐욕’ 알고리즘이다. + +작게는 요리 한 가지를 만드는 일부터 크게는 성간 항해에 이르기까지, 거의 모든 문제의 해결에는 알고리즘이 필요하다. 컴퓨터의 등장으로 우리는 프로그래밍을 통해 자료 구조를 메모리에 저장하고, CPU와 GPU가 알고리즘을 실행하도록 코드를 작성할 수 있게 되었다. 이로써 현실의 문제를 컴퓨터로 옮겨 다양한 복잡한 문제를 더 효율적으로 해결할 수 있다. + +!!! tip "참고" + + 자료 구조, 알고리즘, 배열, 이진 탐색 등의 개념이 아직 막연하게 느껴져도 계속 읽어 나가면 된다. 이 책이 자료 구조와 알고리즘의 지식 세계로 안내할 것이다. diff --git a/ko/docs/chapter_introduction/index.md b/ko/docs/chapter_introduction/index.md new file mode 100644 index 000000000..cc1f06708 --- /dev/null +++ b/ko/docs/chapter_introduction/index.md @@ -0,0 +1,20 @@ +--- +comments: true +icon: material/calculator-variant-outline +--- + +# 제 1 장   알고리즘과의 첫 만남 + +![알고리즘과의 첫 만남](../assets/covers/chapter_introduction.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 한 소녀가 데이터와 어우러져 우아하게 춤을 춘다. 치맛자락에는 알고리즘의 선율이 나부낀다. + + 소녀가 함께 춤추자고 손을 내민다. 그 발걸음을 따라 논리와 아름다움으로 가득한 알고리즘의 세계에 들어서 보자. + +## 이 장의 내용 + +- [1.1   어디에나 있는 알고리즘](algorithms_are_everywhere.md) +- [1.2   알고리즘이란 무엇인가](what_is_dsa.md) +- [1.3   요약](summary.md) diff --git a/ko/docs/chapter_introduction/summary.md b/ko/docs/chapter_introduction/summary.md new file mode 100644 index 000000000..df7c356df --- /dev/null +++ b/ko/docs/chapter_introduction/summary.md @@ -0,0 +1,36 @@ +--- +comments: true +--- + +# 1.3   요약 + +### 1.   핵심 정리 + +- 알고리즘은 일상생활 어디에나 있으며, 손에 닿지 않는 어려운 지식이 아니다. 사실 우리는 이미 자신도 모르게 많은 알고리즘을 익혀 크고 작은 생활 속 문제를 해결하고 있다. +- 사전에서 단어를 찾는 원리는 이진 탐색 알고리즘과 같다. 이진 탐색에는 분할 정복이라는 중요한 알고리즘적 사고가 담겨 있다. +- 카드를 정리하는 과정은 삽입 정렬 알고리즘과 매우 비슷하다. 삽입 정렬은 작은 데이터 집합을 정렬하는 데 적합하다. +- 거스름돈을 주는 과정은 본질적으로 탐욕 알고리즘이다. 각 단계에서 현재 가장 좋아 보이는 선택을 한다. +- 알고리즘은 유한한 시간 안에 특정 문제를 해결하는 명령 또는 작업 단계의 집합이고, 자료 구조는 컴퓨터에서 데이터를 구성하고 저장하는 방식이다. +- 자료 구조와 알고리즘은 긴밀하게 연결되어 있다. 자료 구조는 알고리즘의 토대이며, 알고리즘은 자료 구조에 생명력을 불어넣는다. +- 자료 구조와 알고리즘은 블록 조립에 비유할 수 있다. 블록은 데이터를, 블록의 모양과 연결 방식 등은 자료 구조를, 블록을 조립하는 단계는 알고리즘을 나타낸다. + +### 2.   Q & A + +**Q**: 프로그래머로 일하면서 직접 알고리즘으로 문제를 해결해 본 적이 없다. 자주 쓰는 알고리즘은 프로그래밍 언어에서 이미 제공하므로 그대로 사용하면 된다. 그렇다면 업무에서 다루는 문제가 아직 알고리즘이 필요할 정도는 아니라는 뜻인가? + +구체적인 업무 기술을 무술의 ‘동작’에 비유한다면, 기초 과목은 ‘내공’에 더 가깝다. + +알고리즘을 비롯한 기초 과목을 공부하는 의미는 업무에서 이를 처음부터 직접 구현하는 데 있지 않다고 생각한다. 배운 지식을 바탕으로 문제에 전문적으로 대응하고 판단하여 업무의 전반적인 품질을 높이는 데 의미가 있다. 간단한 예로, 프로그래밍 언어마다 내장 정렬 함수를 제공한다. + +- 자료 구조와 알고리즘을 배우지 않았다면 어떤 데이터든 이 정렬 함수에 넘길 수 있다. 실행도 원활하고 성능도 괜찮으니 별문제가 없어 보인다. +- 하지만 알고리즘을 배웠다면 내장 정렬 함수의 시간 복잡도가 $O(n \log n)$임을 안다. 주어진 데이터가 학번처럼 자릿수가 고정된 정수라면 더 효율적인 ‘기수 정렬’을 사용하여 시간 복잡도를 $O(nk)$로 낮출 수 있다. 여기서 $k$는 자릿수다. 데이터의 양이 많을 때는 이렇게 절약한 실행 시간이 비용 절감이나 사용자 경험 개선 등 큰 가치를 만들어 낼 수 있다. + +공학 분야의 많은 문제는 최적해를 얻기 어렵고, 그저 ‘어느 정도’ 해결된 상태로 남아 있다. 문제의 난이도는 문제 자체의 성질뿐 아니라 그 문제를 바라보는 사람의 지식에도 달려 있다. 지식이 체계적이고 경험이 많을수록 문제를 더 깊이 분석하고 더 우아하게 해결할 수 있다. + +**Q**: 이제는 거의 모든 코드를 AI 에이전트가 작성하는데, 그래도 알고리즘을 배워야 하는가? + +리무(李沐)의 말을 인용하자면, “에이전트는 능력 증폭기다. 능력에 10을 곱하는 것이지, 10을 더하는 덧셈기가 아니다.” + +AI가 능력 증폭기라면 증폭의 바탕이 되는 ‘능력’도 중요하다. 특히 두 가지 능력을 쌓을 가치가 있다고 생각한다. 첫째는 표현 능력, 특히 AI와 협업하는 능력이다. 목표를 명확히 설명하고 배경을 충분히 제공하며 잘못된 방향은 제때 바로잡아야 한다. AI에게 문제를 설명하는 일은 스스로 문제를 명확히 이해하는 데도 도움이 된다. 둘째는 기초 지식이다. 세부 사항은 AI가 보완할 수 있지만, 핵심을 짚어 질문하고 옳고 그름을 판단하며 AI의 출력을 유용한 결과물로 만들려면 기본 개념은 스스로 이해해야 한다. + +AI의 특별한 점은 과거의 도구처럼 기능의 경계가 뚜렷하지 않다는 것이다. AI는 물과 비슷해서 누구의 손에 들어가느냐에 따라 다른 모습을 띤다. 사람의 능력이 뛰어나고 안목이 좋을수록 AI의 증폭 효과도 더욱 뚜렷해진다. diff --git a/ko/docs/chapter_introduction/what_is_dsa.md b/ko/docs/chapter_introduction/what_is_dsa.md new file mode 100644 index 000000000..2b9843085 --- /dev/null +++ b/ko/docs/chapter_introduction/what_is_dsa.md @@ -0,0 +1,65 @@ +--- +comments: true +--- + +# 1.2   알고리즘이란 무엇인가 + +## 1.2.1   알고리즘의 정의 + +알고리즘(algorithm)은 유한한 시간 안에 특정 문제를 해결하는 명령 또는 작업 단계의 집합이며, 다음과 같은 특성을 갖는다. + +- 문제가 명확하고 입력과 출력이 분명하게 정의되어 있다. +- 실행 가능하며, 유한한 단계와 시간, 메모리 공간 안에서 완료할 수 있다. +- 각 단계의 의미가 명확하여 입력과 실행 조건이 같으면 항상 같은 출력을 얻는다. + +## 1.2.2   자료 구조의 정의 + +자료 구조(data structure)는 데이터를 구성하고 저장하는 방식으로, 데이터의 내용, 데이터 사이의 관계, 데이터를 조작하는 방법을 포함한다. 자료 구조는 다음과 같은 목표를 고려하여 설계한다. + +- 컴퓨터 메모리를 절약하도록 공간 사용량을 최대한 줄인다. +- 데이터 접근, 추가, 삭제, 갱신 등의 연산을 최대한 빠르게 수행한다. +- 알고리즘이 효율적으로 실행되도록 간결한 데이터 표현과 논리적 정보를 제공한다. + +**자료 구조를 설계하는 과정에는 수많은 절충이 따른다.** 어느 한 측면을 개선하려면 다른 측면을 양보해야 할 때가 많다. 두 가지 예를 살펴보자. + +- 연결 리스트는 배열보다 데이터를 추가하거나 삭제하기가 편리하지만, 데이터에 접근하는 속도는 느리다. +- 그래프는 연결 리스트보다 더 풍부한 논리적 정보를 제공하지만, 더 많은 메모리 공간이 필요하다. + +## 1.2.3   자료 구조와 알고리즘의 관계 + +아래 그림(1-4)과 같이 자료 구조와 알고리즘은 밀접하게 연결되어 있다. 그 관계는 다음 세 가지 측면에서 살펴볼 수 있다. + +- 자료 구조는 알고리즘의 토대다. 자료 구조는 알고리즘에 체계적으로 저장된 데이터와 이를 조작하는 방법을 제공한다. +- 알고리즘은 자료 구조에 생명력을 불어넣는다. 자료 구조 자체는 데이터 정보만 저장하며, 알고리즘과 결합해야 특정 문제를 해결할 수 있다. +- 같은 알고리즘도 서로 다른 자료 구조를 바탕으로 구현할 수 있지만, 실행 효율은 크게 달라질 수 있다. 따라서 적절한 자료 구조를 선택하는 것이 중요하다. + +![자료 구조와 알고리즘의 관계](what_is_dsa.assets/relationship_between_data_structure_and_algorithm.png){ class="animation-figure" } + +

그림 1-4   자료 구조와 알고리즘의 관계

+ +자료 구조와 알고리즘은 아래 그림(1-5)의 조립 블록에 비유할 수 있다. 블록 한 세트에는 여러 부품뿐 아니라 자세한 조립 설명서도 들어 있다. 설명서를 따라 차례대로 조립하면 멋진 블록 모형을 만들 수 있다. + +![블록 조립](what_is_dsa.assets/assembling_blocks.png){ class="animation-figure" } + +

그림 1-5   블록 조립

+ +구체적인 대응 관계는 아래 표(1-1)와 같다. + +

표 1-1   자료 구조와 알고리즘을 블록 조립에 비유하기

+ +
+ +| 자료 구조와 알고리즘 | 블록 조립 | +| -------------- | ---------------------------------------- | +| 입력 데이터 | 조립하기 전의 블록 | +| 자료 구조 | 모양, 크기, 연결 방식 등 블록의 구성 형태 | +| 알고리즘 | 블록을 원하는 형태로 조립하는 일련의 작업 단계 | +| 출력 데이터 | 완성된 블록 모형 | + +
+ +자료 구조와 알고리즘은 프로그래밍 언어에 종속되지 않는다. 이 때문에 이 책에서는 여러 프로그래밍 언어로 작성한 구현을 제공할 수 있다. + +!!! tip "관례적으로 사용하는 줄임말" + + 실제로 이야기할 때는 ‘자료 구조와 알고리즘’을 흔히 ‘알고리즘’이라고 줄여 부른다. 예를 들어 널리 알려진 리트코드의 알고리즘 문제는 실제로 자료 구조와 알고리즘 두 분야의 지식을 함께 평가한다. diff --git a/ko/docs/chapter_preface/about_the_book.md b/ko/docs/chapter_preface/about_the_book.md new file mode 100644 index 000000000..d648fbee8 --- /dev/null +++ b/ko/docs/chapter_preface/about_the_book.md @@ -0,0 +1,60 @@ +--- +comments: true +--- + +# 0.1   이 책에 대하여 + +이 프로젝트는 오픈 소스로 공개되는 무료 자료 구조와 알고리즘 입문서를 만드는 것을 목표로 한다. 처음 배우는 사람도 쉽게 접근할 수 있는 책을 지향한다. + +- 책 전체에 애니메이션 그림을 사용하여 내용을 명확하고 이해하기 쉽게 설명한다. 난이도를 완만하게 높여 가며 초보자가 자료 구조와 알고리즘의 지식 지도를 탐험하도록 안내한다. +- 소스 코드를 클릭 한 번으로 실행할 수 있다. 독자가 실습을 통해 프로그래밍 능력을 키우고 알고리즘의 동작 원리와 자료 구조의 내부 구현을 이해하도록 돕는다. +- 독자들이 서로 도우며 공부하기를 권장한다. 댓글에서 질문하고 의견을 나누며 함께 성장할 수 있다. + +## 0.1.1   대상 독자 + +알고리즘을 처음 접하는 입문자이거나, 문제를 풀어 본 경험은 조금 있지만 자료 구조와 알고리즘에 대한 이해가 막연해 알 것 같다가도 모르겠다는 생각이 든다면, 이 책은 바로 여러분을 위한 책이다! + +이미 상당한 수의 문제를 풀었고 대부분의 문제 유형에 익숙하다면, 이 책을 통해 알고리즘 지식을 되짚고 체계적으로 정리할 수 있다. 저장소의 소스 코드는 ‘문제 풀이 도구 모음’이나 ‘알고리즘 사전’처럼 활용할 수 있다. + +알고리즘의 ‘고수’라면 소중한 의견을 보내 주거나 [집필에 함께 참여해 주기를](../chapter_appendix/contribution.md) 기대한다. + +!!! success "선수 지식" + + 적어도 한 가지 언어의 프로그래밍 기초를 알고, 간단한 코드를 읽고 작성할 수 있어야 한다. + +## 0.1.2   내용 구성 + +이 책의 주요 내용은 아래 그림(0-1)과 같다. + +- **복잡도 분석**: 자료 구조와 알고리즘을 평가하는 기준과 방법을 다룬다. 시간 복잡도와 공간 복잡도의 계산 방법, 대표적인 유형과 예제 등을 살펴본다. +- **자료 구조**: 기본 자료형과 자료 구조의 분류 방법을 다룬다. 배열, 연결 리스트, 스택, 큐, 해시 테이블, 트리, 힙, 그래프 등의 정의, 장단점, 자주 쓰는 연산, 대표적인 유형과 응용, 구현 방법 등을 살펴본다. +- **알고리즘**: 탐색, 정렬, 분할 정복, 백트래킹, 동적 계획법, 탐욕 등의 정의, 장단점, 효율성, 활용 상황, 문제 해결 단계와 예제 문제 등을 살펴본다. + +![이 책의 주요 내용](about_the_book.assets/hello_algo_mindmap.png){ class="animation-figure" } + +

그림 0-1   이 책의 주요 내용

+ +## 0.1.3   감사의 말 + +이 책은 오픈 소스 커뮤니티의 수많은 기여자가 함께 노력한 덕분에 계속 발전하고 있다. 시간과 노력을 들여 준 모든 집필 참여자에게 감사드린다. 참여자는 다음과 같다(GitHub가 자동으로 생성한 순서): krahets, coderonion, Gonglja, nuomi1, Reanon, justin-tse, hpstory, danielsss, curtishd, night-cruise, S-N-O-R-L-A-X, rongyi, msk397, gvenusleo, khoaxuantu, rivertwilight, K3v123, gyt95, zhuoqinyue, yuelinxin, Zuoxun, mingXta, Phoenix0415, FangYuan33, GN-Yu, longsizhuo, pengchzn, QiLOL, Cathay-Chen, guowei-gong, xBLACKICEx, IsChristina, JoseHung, qualifier1024, hello-ikun, magentaqin, Guanngxu, thomasq0, sunshinesDL, L-Super, Transmigration-zhou, WSL0809, Slone123c, lhxsm, yuan0221, what-is-me, theNefelibatas, Shyam-Chen, sangxiaai, longranger2, codeberg-user, xiongsp, JeffersonHuang, prinpal, seven1240, Wonderdch, malone6, xiaomiusa87, gaofer, bluebean-cloud, a16su, SamJin98, hongyun-robot, nanlei, XiaChuerwu, yd-j, iron-irax, mgisr, steventimes, junminhong, heshuyue, danny900714, Nigh, Dr-XYZ, MolDuM, XC-Zero, reeswell, PXG-XPG, NI-SW, Horbin-Magician, Enlightenus, YangXuanyi, xjr7670, beatrix-chan, DullSword, qq909244296, iStig, boloboloda, hts0000, gledfish, fbigm, echo1937, jiaxianhua, wenjianmin, keshida, kilikilikid, lclc6, lwbaptx, linyejoe2, liuxjerry, szu17dmy, dshlstarr, Yucao-cy, coderlef, czruby, bongbongbakudan, beintentional, ZongYangL, ZhongYuuu, ZhongGuanbin, hezhizhen, linzeyan, ZJKung, JTCPOWI, KawaiiAsh, luluxia, xb534, ztkuaikuai, yw-1021, ElaBosak233, baagod, zhouLion, yishangzhang, yi427, yanedie, yabo083, weibk, wangwang105, th1nk3r-ing, tao363, 4yDX3906, syd168, sslmj2020, smilelsb, siqyka, selear, sdshaoda, Xi-Row, popozhu, nuquist19, noobcodemaker, XiaoK29, chadyi, lyl625760, lucaswangdev, llql1211, 0130w, shanghai-Jerry, EJackYang, Javesun99, eltociear, lipusheng, KNChiu, BlindTerran, ShiMaRing, lovelock, FreddieLi, FloranceYeh, fanchenggang, gltianwen, goerll, nedchu, curly210102, CuB3y0nd, KraHsu, CarrotDLaw, youshaoXG, bubble9um, Asashishi, Asa0oo0o0o, fanenr, eagleanurag, akshiterate, 52coder, foursevenlove, KorsChen, hopkings2008, yang-le, realwujing, Evilrabbit520, Umer-Jahangir, Turing-1024-Lee, Suremotoo, paoxiaomooo, Chieko-Seren, Senrian, Allen-Scai, 19santosh99, ymmmas, Risuntsy, Richard-Zhang1019, RafaelCaso, qingpeng9802, primexiao, Urbaner3, codetypess, nidhoggfgg, MwumLi, CreatorMetaSky, martinx, ZnYang2018, hugtyftg, logan-qiu, psychelzh, Kunchen-Luo, Keynman 및 KeiichiKasai. + +이 책의 코드 검토는 coderonion, curtishd, Gonglja, gvenusleo, hpstory, justin-tse, khoaxuantu, krahets, night-cruise, nuomi1, Reanon 및 rongyi가 맡았다(알파벳순). 시간과 노력을 들여 준 이들에게 감사드린다. 이들 덕분에 각 언어의 코드가 일관된 규칙과 형식을 유지할 수 있었다. + +영문판은 yuelinxin, K3v123, magentaqin, QiLOL, Phoenix0415, SamJin98, yanedie, RafaelCaso, pengchzn 및 thomasq0가 검토했다. 일본어판은 eltociear가, 러시아어판은 И. А. Шевкун과 Yuyan Huang이, 번체 중국어판은 Shyam-Chen과 Dr-XYZ가 검토했다. 이들의 기여 덕분에 더 많은 독자에게 이 책을 전할 수 있게 되었다. 모두에게 감사드린다. + +이 책의 ePub 전자책 생성 도구는 zhongfq가 개발했다. 독자에게 더 유연한 독서 방식을 제공해 준 그의 기여에 감사드린다. + +이 책을 쓰는 동안 많은 분의 도움을 받았다. + +- 회사에서 나의 멘토였던 리시(李汐) 박사에게 감사드린다. 긴 대화 중 “어서 행동에 옮겨 보라”고 격려해 준 덕분에 이 책을 쓰겠다는 결심을 굳힐 수 있었다. +- 이 책의 첫 독자가 되어 준 여자 친구 파오파오(泡泡)에게 감사드린다. 알고리즘 입문자의 시각에서 많은 소중한 의견을 주어 초보자가 읽기 더 좋은 책이 되었다. +- 텅바오(腾宝), 치바오(琦宝), 페이바오(飞宝)에게 감사드린다. 이 책에 창의적인 이름을 지어 주어 모두가 첫 코드 “Hello World!”를 작성하던 아름다운 기억을 떠올리게 했다. +- 지식재산권에 관한 전문적인 도움을 준 샤오취안(校铨)에게 감사드린다. 이 도움은 오픈 소스 도서인 이 책을 완성해 가는 데 중요한 역할을 했다. +- 아름다운 표지와 로고를 디자인해 준 쑤퉁(苏潼)에게 감사드린다. 나의 지나친 꼼꼼함 때문에 여러 번 수정을 요청했는데도 인내심 있게 작업해 주었다. +- 편집과 조판에 관한 조언을 해 주고 오픈 소스 문서 테마 [Material-for-MkDocs](https://github.com/squidfunk/mkdocs-material)를 개발한 @squidfunk에게 감사드린다. + +집필하면서 자료 구조와 알고리즘에 관한 많은 교재와 글을 읽었다. 이 작품들은 훌륭한 본보기가 되어 이 책의 정확성과 품질을 뒷받침해 주었다. 모든 선생님과 선배의 뛰어난 기여에 감사드린다! + +이 책은 손으로 실습하고 머리로 생각하며 배우는 방식을 권장한다. 이 점에서는 [『실습으로 배우는 딥러닝(Dive into Deep Learning)』](https://github.com/d2l-ai/d2l-zh)에서 깊은 영감을 받았다. 독자 여러분에게 이 훌륭한 책을 적극 추천한다. + +**부모님께 진심으로 감사드린다. 늘 보내 주신 지지와 격려 덕분에 이처럼 흥미로운 일을 할 기회를 얻었다.** diff --git a/ko/docs/chapter_preface/index.md b/ko/docs/chapter_preface/index.md new file mode 100644 index 000000000..2ed23c9f8 --- /dev/null +++ b/ko/docs/chapter_preface/index.md @@ -0,0 +1,20 @@ +--- +comments: true +icon: material/book-open-outline +--- + +# 제 0 장   들어가며 + +![들어가며](../assets/covers/chapter_preface.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 알고리즘은 아름다운 교향곡과 같다. 코드 한 줄 한 줄이 리듬처럼 흐른다. + + 이 책이 여러분의 머릿속에 잔잔히 울려 퍼져, 특별하고 깊은 선율을 남기기를 바란다. + +## 이 장의 내용 + +- [0.1   이 책에 대하여](about_the_book.md) +- [0.2   이 책을 활용하는 방법](suggestions.md) +- [0.3   요약](summary.md) diff --git a/ko/docs/chapter_preface/suggestions.md b/ko/docs/chapter_preface/suggestions.md new file mode 100644 index 000000000..59b8764d5 --- /dev/null +++ b/ko/docs/chapter_preface/suggestions.md @@ -0,0 +1,257 @@ +--- +comments: true +--- + +# 0.2   이 책을 활용하는 방법 + +!!! tip "참고" + + 가장 좋은 독서 경험을 위해 이 절을 끝까지 읽어 보기를 권장한다. + +## 0.2.1   글의 표현과 서식에 관한 약속 + +- 제목 뒤에 `*`가 붙은 절은 선택해서 읽는 심화 내용이다. 시간이 부족하면 우선 건너뛰어도 된다. +- 웹 버전에서는 전문 용어를 밑줄로 표시한다. 예를 들면 배열(array)과 같다. 문헌을 읽을 때 도움이 되므로 용어를 기억해 두기를 권장한다. +- 핵심 내용과 요약 문장은 **굵게 표시**한다. 이러한 문장은 특히 주의 깊게 읽을 필요가 있다. +- 특정한 의미로 사용하는 단어나 문구는 모호함을 피하기 위해 ‘따옴표’로 표시한다. +- 프로그래밍 언어마다 명칭이 다를 때는 Python을 기준으로 삼는다. 예를 들어 ‘비어 있음’을 나타낼 때 `None`을 사용한다. +- 이 책은 내용을 더 간결하게 배치하기 위해 프로그래밍 언어의 주석 작성 규칙을 일부 따르지 않는다. 주석은 주로 제목 주석, 설명 주석, 여러 줄 주석의 세 유형으로 나뉜다. + +=== "Python" + + ```python title="" + """함수, 클래스, 테스트 사례 등을 표시하는 제목 주석""" + + # 코드를 자세히 설명하는 설명 주석 + + """ + 여러 줄 + 주석 + """ + ``` + +=== "C++" + + ```cpp title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "Java" + + ```java title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "C#" + + ```csharp title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "Go" + + ```go title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "Swift" + + ```swift title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "JS" + + ```javascript title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "TS" + + ```typescript title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "Dart" + + ```dart title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "Rust" + + ```rust title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + // 여러 줄 + // 주석 + ``` + +=== "C" + + ```c title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "Kotlin" + + ```kotlin title="" + /* 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 */ + + // 코드를 자세히 설명하는 설명 주석 + + /** + * 여러 줄 + * 주석 + */ + ``` + +=== "Ruby" + + ```ruby title="" + ### 함수, 클래스, 테스트 사례 등을 표시하는 제목 주석 ### + + # 코드를 자세히 설명하는 설명 주석 + + # 여러 줄 + # 주석 + ``` + +## 0.2.2   애니메이션 그림으로 효율적으로 배우기 + +영상과 그림은 글보다 정보 밀도가 높고 구조가 뚜렷하여 이해하기 쉽다. 이 책에서는 **핵심 내용과 어려운 개념을 주로 애니메이션 그림으로 보여 주고**, 글은 설명과 보충을 담당한다. + +책을 읽다가 아래 그림(0-2)과 같은 애니메이션 설명을 만나면 **그림을 중심으로 보고 글을 보조 자료로 활용하여** 두 가지를 함께 이해하기 바란다. + +![애니메이션 그림 예시](../index.assets/animation.gif){ class="animation-figure" } + +

그림 0-2   애니메이션 그림 예시

+ +## 0.2.3   코드 실습으로 이해를 깊게 하기 + +이 책의 예제 코드는 [GitHub 저장소](https://github.com/krahets/hello-algo)에 있다. 아래 그림(0-3)과 같이 **소스 코드에는 테스트 사례가 포함되어 있어 클릭 한 번으로 실행할 수 있다.** + +시간이 허락한다면 **예제 코드를 참고하여 직접 한 번 작성해 보기를 권장한다.** 공부할 시간이 부족하더라도 적어도 모든 코드를 읽고 실행해 보자. + +코드를 읽는 것보다 직접 작성하는 과정에서 더 많은 것을 얻는 경우가 많다. **직접 해 보며 배우는 것이 진짜 배움이다.** + +![코드 실행 예시](../index.assets/running_code.gif){ class="animation-figure" } + +

그림 0-3   코드 실행 예시

+ +코드를 실행하기 위한 준비는 크게 세 단계로 나뉜다. + +**1단계: 로컬 프로그래밍 환경 설치**. 부록의 [안내](../chapter_appendix/installation.md)에 따라 설치한다. 이미 설치했다면 이 단계는 건너뛰어도 된다. + +**2단계: 코드 저장소 복제 또는 내려받기**. [GitHub 저장소](https://github.com/krahets/hello-algo)로 이동한다. [Git](https://git-scm.com/downloads)이 설치되어 있다면 다음 명령으로 저장소를 복제할 수 있다. + +```shell +git clone https://github.com/krahets/hello-algo.git +``` + +물론 아래 그림(0-4)에 표시된 위치에서 ‘Download ZIP’ 버튼을 눌러 코드 압축 파일을 바로 내려받은 뒤 로컬에서 압축을 풀어도 된다. + +![저장소 복제와 코드 내려받기](suggestions.assets/download_code.png){ class="animation-figure" } + +

그림 0-4   저장소 복제와 코드 내려받기

+ +**3단계: 소스 코드 실행**. 아래 그림(0-5)처럼 위쪽에 파일명이 표시된 코드 블록은 저장소의 `ko/codes` 폴더에서 해당 한국어판 소스 파일을 찾을 수 있다. 소스 파일은 클릭 한 번으로 실행할 수 있으므로 불필요한 디버깅 시간을 줄이고 학습 내용에 집중할 수 있다. + +![코드 블록과 이에 대응하는 소스 파일](suggestions.assets/code_md_to_repo.png){ class="animation-figure" } + +

그림 0-5   코드 블록과 이에 대응하는 소스 파일

+ +로컬에서 실행하는 것 외에 **웹 버전에서는 Python 코드의 실행 과정을 시각화할 수도 있다**([pythontutor](https://pythontutor.com/) 기반). 아래 그림(0-6)처럼 코드 블록 아래의 ‘코드 시각화’를 누르면 화면이 펼쳐져 알고리즘 코드의 실행 과정을 관찰할 수 있다. ‘전체 화면’을 누르면 더 넓은 화면에서 볼 수 있다. + +![Python 코드 실행 시각화](suggestions.assets/pythontutor_example.png){ class="animation-figure" } + +

그림 0-6   Python 코드 실행 시각화

+ +## 0.2.4   질문과 토론으로 함께 성장하기 + +책을 읽다가 이해하지 못한 내용을 만나도 쉽게 건너뛰지 않기를 바란다. **댓글란에 질문을 남기면** 나와 동료들이 성심껏 답변하겠다. 보통 이틀 안에 답변을 받을 수 있다. + +아래 그림(0-7)처럼 웹 버전의 각 장 아래에는 댓글란이 있다. 댓글의 내용에도 관심을 가져 주기 바란다. 다른 사람들이 겪은 문제를 살펴보면서 놓친 내용을 보완하고 더 깊이 생각할 수 있다. 또한 다른 독자의 질문에 답하고 자신의 생각을 나누어 서로의 성장을 도울 수 있기를 기대한다. + +![댓글란 예시](../index.assets/comment.gif){ class="animation-figure" } + +

그림 0-7   댓글란 예시

+ +## 0.2.5   알고리즘 학습 경로 + +자료 구조와 알고리즘을 배우는 과정은 크게 세 단계로 나눌 수 있다. + +1. **1단계: 알고리즘 입문**. 다양한 자료 구조의 특성과 사용법에 익숙해지고, 각 알고리즘의 원리, 절차, 용도, 효율성 등을 배운다. +2. **2단계: 알고리즘 문제 풀기**. 많이 다루는 문제부터 시작하여 최소 100문제 정도를 풀고 주요 알고리즘 문제에 익숙해지기를 권장한다. 처음 문제를 풀 때는 배운 내용을 잊는 일이 어려움으로 느껴질 수 있지만, 이는 매우 자연스러운 일이므로 걱정하지 않아도 된다. ‘에빙하우스의 망각 곡선’에 맞추어 문제를 복습하면 보통 3~5번 반복한 뒤에는 확실하게 기억할 수 있다. 추천 문제 목록과 학습 계획은 이 [GitHub 저장소](https://github.com/krahets/LeetCode-Book)를 참고하기 바란다. +3. **3단계: 지식 체계 세우기**. 학습 측면에서는 알고리즘 전문 글, 문제 해결 틀, 알고리즘 교재를 읽으며 지식 체계를 꾸준히 넓힐 수 있다. 문제 풀이 측면에서는 주제별 분류, 한 문제를 여러 방법으로 풀기, 한 해법을 여러 문제에 적용하기 등의 심화 전략을 시도할 수 있다. 관련 경험과 조언은 여러 커뮤니티에서 찾아볼 수 있다. + +아래 그림(0-8)처럼 이 책은 주로 ‘1단계’를 다루며, 이후 2단계와 3단계의 학습을 더 효율적으로 진행하도록 돕는 것을 목표로 한다. + +![알고리즘 학습 경로](suggestions.assets/learning_route.png){ class="animation-figure" } + +

그림 0-8   알고리즘 학습 경로

diff --git a/ko/docs/chapter_preface/summary.md b/ko/docs/chapter_preface/summary.md new file mode 100644 index 000000000..87d8aa9cf --- /dev/null +++ b/ko/docs/chapter_preface/summary.md @@ -0,0 +1,14 @@ +--- +comments: true +--- + +# 0.3   요약 + +### 1.   핵심 정리 + +- 이 책은 주로 알고리즘 입문자를 대상으로 한다. 이미 기초를 갖추었다면 알고리즘 지식을 체계적으로 복습하는 데 도움이 되며, 소스 코드는 ‘문제 풀이 도구 모음’으로 활용할 수 있다. +- 내용은 크게 복잡도 분석, 자료 구조, 알고리즘의 세 부분으로 구성되어 있으며, 이 분야의 주요 주제를 폭넓게 다룬다. +- 알고리즘 초보자에게는 처음 공부할 때 입문서를 읽는 일이 매우 중요하다. 불필요하게 돌아가는 길을 많이 줄일 수 있다. +- 애니메이션 그림은 주로 핵심 내용과 어려운 개념을 설명하는 데 쓰인다. 책을 읽을 때 이러한 내용을 특히 주의 깊게 살펴보자. +- 실습은 프로그래밍을 배우는 가장 좋은 방법이다. 소스 코드를 실행하고 직접 코드를 작성해 볼 것을 적극 권장한다. +- 웹 버전의 각 장에는 댓글란이 있다. 궁금한 점이나 생각을 언제든 나누기 바란다. diff --git a/ko/docs/chapter_reference/index.md b/ko/docs/chapter_reference/index.md new file mode 100644 index 000000000..ae0ea3607 --- /dev/null +++ b/ko/docs/chapter_reference/index.md @@ -0,0 +1,25 @@ +--- +icon: material/bookshelf +--- + +# 참고 문헌 + +[1] Thomas H. Cormen, et al. Introduction to Algorithms (3rd Edition). + +[2] Aditya Bhargava. Grokking Algorithms: An Illustrated Guide for Programmers and Other Curious People (1st Edition). + +[3] Robert Sedgewick, et al. Algorithms (4th Edition). + +[4] 옌웨이민(严蔚敏). 『자료 구조(C 언어판)』(数据结构(C 语言版)). + +[5] 덩쥔후이(邓俊辉). 『자료 구조(C++ 언어판, 제3판)』(数据结构(C++ 语言版,第三版)). + +[6] Mark Allen Weiss 지음, 천웨(陈越) 옮김. 『자료 구조와 알고리즘 분석: Java 언어로 설명하기(제3판)』(数据结构与算法分析:Java语言描述(第三版)). + +[7] 청제(程杰). 『쉽게 풀어 쓴 자료 구조』(大话数据结构). + +[8] 왕정(王争). 『자료 구조와 알고리즘의 아름다움』(数据结构与算法之美). + +[9] Gayle Laakmann McDowell. Cracking the Coding Interview: 189 Programming Questions and Solutions (6th Edition). + +[10] Aston Zhang, et al. Dive into Deep Learning. diff --git a/ko/docs/chapter_searching/binary_search.md b/ko/docs/chapter_searching/binary_search.md new file mode 100644 index 000000000..e5b49d661 --- /dev/null +++ b/ko/docs/chapter_searching/binary_search.md @@ -0,0 +1,713 @@ +--- +comments: true +--- + +# 10.1   이진 탐색 + +이진 탐색(binary search)은 분할 정복 전략에 기반한 효율적인 탐색 알고리즘이다. 데이터가 정렬되어 있다는 성질을 이용하여 매번 탐색 범위를 절반으로 줄이며, 목표 원소를 찾거나 탐색 구간이 빌 때까지 반복한다. + +!!! question "문제" + + 길이가 $n$인 배열 `nums`가 주어진다. 원소는 오름차순으로 정렬되어 있고 중복되지 않는다. 이 배열에서 원소 `target`을 찾아 그 인덱스를 반환하라. 배열에 해당 원소가 없으면 $-1$을 반환하라. 예시는 아래 그림(10-1)과 같다. + +![이진 탐색 예시 데이터](binary_search.assets/binary_search_example.png){ class="animation-figure" } + +

그림 10-1   이진 탐색 예시 데이터

+ +아래 그림(10-2)과 같이 포인터 $i = 0$과 $j = n - 1$을 초기화하여 각각 배열의 첫 원소와 마지막 원소를 가리키게 한다. 이는 탐색 구간 $[0, n - 1]$을 나타낸다. 대괄호는 경곗값 자체를 포함하는 닫힌 구간을 뜻한다. + +이어서 다음 두 단계를 반복한다. + +1. 중간 인덱스 $m = \lfloor {(i + j) / 2} \rfloor$를 계산한다. 여기서 $\lfloor \: \rfloor$는 내림 연산이다. +2. `nums[m]`과 `target`의 대소 관계를 비교하며, 다음 세 경우로 나뉜다. + 1. `nums[m] < target`이면 `target`은 구간 $[m + 1, j]$에 있으므로 $i = m + 1$을 수행한다. + 2. `nums[m] > target`이면 `target`은 구간 $[i, m - 1]$에 있으므로 $j = m - 1$을 수행한다. + 3. `nums[m] = target`이면 `target`을 찾았으므로 인덱스 $m$을 반환한다. + +배열에 목표 원소가 없으면 탐색 구간은 결국 비게 된다. 이때 $-1$을 반환한다. + +=== "<1>" + ![이진 탐색 과정](binary_search.assets/binary_search_step1.png){ class="animation-figure" } + +=== "<2>" + ![이진 탐색 2단계](binary_search.assets/binary_search_step2.png){ class="animation-figure" } + +=== "<3>" + ![이진 탐색 3단계](binary_search.assets/binary_search_step3.png){ class="animation-figure" } + +=== "<4>" + ![이진 탐색 4단계](binary_search.assets/binary_search_step4.png){ class="animation-figure" } + +=== "<5>" + ![이진 탐색 5단계](binary_search.assets/binary_search_step5.png){ class="animation-figure" } + +=== "<6>" + ![이진 탐색 6단계](binary_search.assets/binary_search_step6.png){ class="animation-figure" } + +=== "<7>" + ![이진 탐색 7단계](binary_search.assets/binary_search_step7.png){ class="animation-figure" } + +

그림 10-2   이진 탐색 과정

+ +$i$와 $j$가 모두 `int` 타입이므로 **$i + j$가 `int` 타입의 표현 범위를 벗어날 수 있다**. 정수 오버플로를 피하기 위해 보통 $m = \lfloor {i + (j - i) / 2} \rfloor$ 공식으로 중간 인덱스를 계산한다. + +코드는 다음과 같다. + +=== "Python" + + ```python title="binary_search.py" + def binary_search(nums: list[int], target: int) -> int: + """이진 탐색(양 끝을 포함하는 닫힌 구간)""" + # 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + i, j = 0, len(nums) - 1 + # 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while i <= j: + # 이론적으로 Python의 수는 메모리 크기에 따라 무한히 커질 수 있으므로 큰 수의 범위 초과를 고려할 필요가 없다 + m = (i + j) // 2 # 중간 인덱스 m을 계산한다 + if nums[m] < target: + i = m + 1 # 이 경우 target은 구간 [m+1, j]에 있다 + elif nums[m] > target: + j = m - 1 # 이 경우 target은 구간 [i, m-1]에 있다 + else: + return m # 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return -1 # 목표 원소를 찾지 못했으므로 -1을 반환한다 + ``` + +=== "C++" + + ```cpp title="binary_search.cpp" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + int binarySearch(vector &nums, int target) { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + int i = 0, j = nums.size() - 1; + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1; + else if (nums[m] > target) // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1; + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "Java" + + ```java title="binary_search.java" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + int binarySearch(int[] nums, int target) { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + int i = 0, j = nums.length - 1; + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1; + else if (nums[m] > target) // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1; + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "C#" + + ```csharp title="binary_search.cs" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + int BinarySearch(int[] nums, int target) { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + int i = 0, j = nums.Length - 1; + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1; + else if (nums[m] > target) // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1; + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "Go" + + ```go title="binary_search.go" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + func binarySearch(nums []int, target int) int { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + i, j := 0, len(nums)-1 + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + for i <= j { + m := i + (j-i)/2 // 중간 인덱스 m을 계산한다 + if nums[m] < target { // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1 + } else if nums[m] > target { // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1 + } else { // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + } + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1 + } + ``` + +=== "Swift" + + ```swift title="binary_search.swift" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + func binarySearch(nums: [Int], target: Int) -> Int { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + var i = nums.startIndex + var j = nums.endIndex - 1 + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while i <= j { + let m = i + (j - i) / 2 // 중간 인덱스 m을 계산한다 + if nums[m] < target { // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1 + } else if nums[m] > target { // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1 + } else { // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + } + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1 + } + ``` + +=== "JS" + + ```javascript title="binary_search.js" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + function binarySearch(nums, target) { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + let i = 0, + j = nums.length - 1; + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while (i <= j) { + // 중간 인덱스 m을 계산하며, parseInt()로 소수 부분을 버린다 + const m = parseInt(i + (j - i) / 2); + if (nums[m] < target) + // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1; + else if (nums[m] > target) + // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1; + else return m; // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "TS" + + ```typescript title="binary_search.ts" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + function binarySearch(nums: number[], target: number): number { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + let i = 0, + j = nums.length - 1; + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while (i <= j) { + // 중간 인덱스 m을 계산한다 + const m = Math.floor(i + (j - i) / 2); + if (nums[m] < target) { + // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1; + } else if (nums[m] > target) { + // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1; + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + return -1; // 목표 원소를 찾지 못했으므로 -1을 반환한다 + } + ``` + +=== "Dart" + + ```dart title="binary_search.dart" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + int binarySearch(List nums, int target) { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + int i = 0, j = nums.length - 1; + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while (i <= j) { + int m = i + (j - i) ~/ 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1; + } else if (nums[m] > target) { + // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1; + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "Rust" + + ```rust title="binary_search.rs" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + fn binary_search(nums: &[i32], target: i32) -> i32 { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + let mut i = 0; + let mut j = nums.len() as i32 - 1; + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while i <= j { + let m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if nums[m as usize] < target { + // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1; + } else if nums[m as usize] > target { + // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1; + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "C" + + ```c title="binary_search.c" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + int binarySearch(int *nums, int len, int target) { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + int i = 0, j = len - 1; + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1; + else if (nums[m] > target) // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1; + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search.kt" + /* 이진 탐색(양 끝을 포함하는 닫힌 구간) */ + fun binarySearch(nums: IntArray, target: Int): Int { + // 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + var i = 0 + var j = nums.size - 1 + // 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while (i <= j) { + val m = i + (j - i) / 2 // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j]에 있다 + i = m + 1 + else if (nums[m] > target) // 이 경우 target은 구간 [i, m-1]에 있다 + j = m - 1 + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1 + } + ``` + +=== "Ruby" + + ```ruby title="binary_search.rb" + ### 이진 탐색(양 끝을 포함하는 닫힌 구간) ### + def binary_search(nums, target) + # 닫힌 구간 [0, n-1]을 초기화한다. i와 j는 각각 배열의 첫 원소와 마지막 원소를 가리킨다 + i, j = 0, nums.length - 1 + + # 반복하다 탐색 구간이 비면 종료한다(i > j이면 빈 구간이다) + while i <= j + # Ruby의 정수는 이론상 메모리가 허용하는 만큼 커질 수 있으므로 큰 수의 오버플로를 고려할 필요가 없다 + m = (i + j) / 2 # 중간 인덱스 m을 계산한다 + + if nums[m] < target + i = m + 1 # 이 경우 target은 구간 [m+1, j]에 있다 + elsif nums[m] > target + j = m - 1 # 이 경우 target은 구간 [i, m-1]에 있다 + else + return m # 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + end + end + + -1 # 목표 원소를 찾지 못했으므로 -1을 반환한다 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +**시간 복잡도는 $O(\log n)$이다**. 이진 탐색의 반복문에서 구간이 매번 절반으로 줄어들므로 반복 횟수는 $\log_2 n$이다. + +**공간 복잡도는 $O(1)$이다**. 포인터 $i$와 $j$는 상수 크기의 공간을 사용한다. + +## 10.1.1   구간 표현 방법 + +위의 양끝이 닫힌 구간 외에 '왼쪽은 닫히고 오른쪽은 열린' 구간도 자주 사용한다. $[0, n)$으로 정의하며 왼쪽 경곗값은 포함하고 오른쪽 경곗값은 포함하지 않는다. 이 표현에서 구간 $[i, j)$는 $i = j$일 때 비어 있다. + +이 표현을 사용하여 같은 기능의 이진 탐색 알고리즘을 구현할 수 있다. + +=== "Python" + + ```python title="binary_search.py" + def binary_search_lcro(nums: list[int], target: int) -> int: + """이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간)""" + # 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + i, j = 0, len(nums) + # 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while i < j: + m = (i + j) // 2 # 중간 인덱스 m을 계산한다 + if nums[m] < target: + i = m + 1 # 이 경우 target은 구간 [m+1, j)에 있다 + elif nums[m] > target: + j = m # 이 경우 target은 구간 [i, m)에 있다 + else: + return m # 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return -1 # 목표 원소를 찾지 못했으므로 -1을 반환한다 + ``` + +=== "C++" + + ```cpp title="binary_search.cpp" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + int binarySearchLCRO(vector &nums, int target) { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + int i = 0, j = nums.size(); + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while (i < j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1; + else if (nums[m] > target) // 이 경우 target은 구간 [i, m)에 있다 + j = m; + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "Java" + + ```java title="binary_search.java" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + int binarySearchLCRO(int[] nums, int target) { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + int i = 0, j = nums.length; + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while (i < j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1; + else if (nums[m] > target) // 이 경우 target은 구간 [i, m)에 있다 + j = m; + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "C#" + + ```csharp title="binary_search.cs" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + int BinarySearchLCRO(int[] nums, int target) { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + int i = 0, j = nums.Length; + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while (i < j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1; + else if (nums[m] > target) // 이 경우 target은 구간 [i, m)에 있다 + j = m; + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "Go" + + ```go title="binary_search.go" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + func binarySearchLCRO(nums []int, target int) int { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + i, j := 0, len(nums) + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + for i < j { + m := i + (j-i)/2 // 중간 인덱스 m을 계산한다 + if nums[m] < target { // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1 + } else if nums[m] > target { // 이 경우 target은 구간 [i, m)에 있다 + j = m + } else { // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + } + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1 + } + ``` + +=== "Swift" + + ```swift title="binary_search.swift" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + func binarySearchLCRO(nums: [Int], target: Int) -> Int { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + var i = nums.startIndex + var j = nums.endIndex + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while i < j { + let m = i + (j - i) / 2 // 중간 인덱스 m을 계산한다 + if nums[m] < target { // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1 + } else if nums[m] > target { // 이 경우 target은 구간 [i, m)에 있다 + j = m + } else { // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + } + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1 + } + ``` + +=== "JS" + + ```javascript title="binary_search.js" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + function binarySearchLCRO(nums, target) { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + let i = 0, + j = nums.length; + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while (i < j) { + // 중간 인덱스 m을 계산하며, parseInt()로 소수 부분을 버린다 + const m = parseInt(i + (j - i) / 2); + if (nums[m] < target) + // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1; + else if (nums[m] > target) + // 이 경우 target은 구간 [i, m)에 있다 + j = m; + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + else return m; + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "TS" + + ```typescript title="binary_search.ts" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + function binarySearchLCRO(nums: number[], target: number): number { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + let i = 0, + j = nums.length; + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while (i < j) { + // 중간 인덱스 m을 계산한다 + const m = Math.floor(i + (j - i) / 2); + if (nums[m] < target) { + // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1; + } else if (nums[m] > target) { + // 이 경우 target은 구간 [i, m)에 있다 + j = m; + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + return -1; // 목표 원소를 찾지 못했으므로 -1을 반환한다 + } + ``` + +=== "Dart" + + ```dart title="binary_search.dart" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + int binarySearchLCRO(List nums, int target) { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + int i = 0, j = nums.length; + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while (i < j) { + int m = i + (j - i) ~/ 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1; + } else if (nums[m] > target) { + // 이 경우 target은 구간 [i, m)에 있다 + j = m; + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "Rust" + + ```rust title="binary_search.rs" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + fn binary_search_lcro(nums: &[i32], target: i32) -> i32 { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + let mut i = 0; + let mut j = nums.len() as i32; + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while i < j { + let m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if nums[m as usize] < target { + // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1; + } else if nums[m as usize] > target { + // 이 경우 target은 구간 [i, m)에 있다 + j = m; + } else { + // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "C" + + ```c title="binary_search.c" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + int binarySearchLCRO(int *nums, int len, int target) { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + int i = 0, j = len; + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while (i < j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1; + else if (nums[m] > target) // 이 경우 target은 구간 [i, m)에 있다 + j = m; + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m; + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1; + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search.kt" + /* 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) */ + fun binarySearchLCRO(nums: IntArray, target: Int): Int { + // 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + var i = 0 + var j = nums.size + // 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while (i < j) { + val m = i + (j - i) / 2 // 중간 인덱스 m을 계산한다 + if (nums[m] < target) // 이 경우 target은 구간 [m+1, j)에 있다 + i = m + 1 + else if (nums[m] > target) // 이 경우 target은 구간 [i, m)에 있다 + j = m + else // 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + return m + } + // 목표 원소를 찾지 못했으므로 -1을 반환한다 + return -1 + } + ``` + +=== "Ruby" + + ```ruby title="binary_search.rb" + ### 이진 탐색(왼쪽 끝은 포함하고 오른쪽 끝은 제외하는 구간) ### + def binary_search_lcro(nums, target) + # 왼쪽 끝만 포함하는 구간 [0, n)을 초기화한다. i는 배열의 첫 원소를, j는 마지막 원소 바로 다음 위치를 가리킨다 + i, j = 0, nums.length + + # 반복하다 탐색 구간이 비면 종료한다(i = j이면 빈 구간이다) + while i < j + # 중간 인덱스 m을 계산한다 + m = (i + j) / 2 + + if nums[m] < target + i = m + 1 # 이 경우 target은 구간 [m+1, j)에 있다 + elsif nums[m] > target + j = m - 1 # 이 경우 target은 구간 [i, m)에 있다 + else + return m # 목표 원소를 찾았으므로 해당 인덱스를 반환한다 + end + end + + -1 # 목표 원소를 찾지 못했으므로 -1을 반환한다 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +아래 그림(10-3)과 같이 구간을 표현하는 방식에 따라 이진 탐색 알고리즘의 초기화, 반복 조건, 구간 축소 연산이 모두 달라진다. + +'양끝이 닫힌 구간'에서는 왼쪽과 오른쪽 경계를 모두 포함하므로, 포인터 $i$와 $j$로 구간을 줄이는 연산도 대칭적이다. 실수할 가능성이 더 작으므로 **일반적으로 '양끝이 닫힌 구간'으로 작성하기를 권장한다**. + +![두 가지 구간 정의](binary_search.assets/binary_search_ranges.png){ class="animation-figure" } + +

그림 10-3   두 가지 구간 정의

+ +## 10.1.2   장점과 한계 + +이진 탐색은 시간과 공간 양쪽에서 좋은 성능을 보인다. + +- 이진 탐색은 시간 효율이 높다. 데이터가 많을수록 로그 시간 복잡도의 이점이 뚜렷하다. 예를 들어 데이터 크기가 $n = 2^{20}$이면 선형 탐색은 $2^{20} = 1048576$ 번 반복해야 하지만, 이진 탐색은 $\log_2 2^{20} = 20$ 번이면 된다. +- 이진 탐색은 추가 공간이 필요하지 않다. 해시 탐색처럼 추가 공간을 사용하는 탐색 알고리즘보다 공간을 절약한다. + +하지만 이진 탐색이 모든 상황에 적합한 것은 아니다. 주요 이유는 다음과 같다. + +- 이진 탐색은 정렬된 데이터에만 적용할 수 있다. 입력이 정렬되지 않았다면 이진 탐색만을 위해 정렬하는 것은 오히려 손해다. 정렬 알고리즘의 시간 복잡도는 보통 $O(n \log n)$으로 선형 탐색과 이진 탐색보다 높기 때문이다. 원소를 자주 삽입하는 상황에서도 정렬 상태를 유지하려면 특정 위치에 삽입해야 하므로, $O(n)$의 큰 시간 비용이 든다. +- 이진 탐색은 배열에만 적합하다. 원소를 건너뛰며(연속적이지 않게) 접근해야 하는데, 연결 리스트에서는 이런 접근이 비효율적이다. 따라서 연결 리스트나 연결 리스트 기반 자료 구조에는 적합하지 않다. +- 데이터가 적을 때는 선형 탐색의 성능이 더 좋다. 선형 탐색은 매번 비교 1회만 필요하지만, 이진 탐색은 덧셈 1회, 나눗셈 1회, 비교 1 ~ 3회, 덧셈(뺄셈) 1회로 총 4 ~ 6회의 기본 연산이 필요하다. 따라서 데이터 수 $n$이 작으면 선형 탐색이 오히려 더 빠르다. diff --git a/ko/docs/chapter_searching/binary_search_edge.md b/ko/docs/chapter_searching/binary_search_edge.md new file mode 100644 index 000000000..75556e186 --- /dev/null +++ b/ko/docs/chapter_searching/binary_search_edge.md @@ -0,0 +1,503 @@ +--- +comments: true +--- + +# 10.3   이진 탐색으로 경계 찾기 + +## 10.3.1   왼쪽 경계 찾기 + +!!! question "문제" + + 중복 원소를 포함할 수 있는 길이 $n$의 정렬된 배열 `nums`가 주어진다. 배열에서 가장 왼쪽에 있는 원소 `target`의 인덱스를 반환하라. 해당 원소가 없으면 $-1$을 반환하라. + +이진 탐색으로 삽입 위치를 찾는 방법을 떠올려 보자. 탐색이 끝나면 $i$는 가장 왼쪽의 `target`을 가리킨다. **따라서 삽입 위치 찾기는 본질적으로 가장 왼쪽 `target`의 인덱스를 찾는 과정이다**. + +삽입 위치를 찾는 함수를 이용하여 왼쪽 경계를 찾아 보자. 배열에 `target`이 없을 수도 있으며, 이때는 다음 두 결과 중 하나가 나올 수 있다. + +- 삽입 위치의 인덱스 $i$가 범위를 벗어난다. +- 원소 `nums[i]`가 `target`과 다르다. + +위 두 경우에는 $-1$을 바로 반환하면 된다. 코드는 다음과 같다. + +=== "Python" + + ```python title="binary_search_edge.py" + def binary_search_left_edge(nums: list[int], target: int) -> int: + """가장 왼쪽에 있는 target을 이진 탐색""" + # target의 삽입 위치를 찾는 것과 같다 + i = binary_search_insertion(nums, target) + # target을 찾지 못했으므로 -1을 반환한다 + if i == len(nums) or nums[i] != target: + return -1 + # target을 찾았으므로 인덱스 i를 반환한다 + return i + ``` + +=== "C++" + + ```cpp title="binary_search_edge.cpp" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + int binarySearchLeftEdge(vector &nums, int target) { + // target의 삽입 위치를 찾는 것과 같다 + int i = binarySearchInsertion(nums, target); + // target을 찾지 못했으므로 -1을 반환한다 + if (i == nums.size() || nums[i] != target) { + return -1; + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i; + } + ``` + +=== "Java" + + ```java title="binary_search_edge.java" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + int binarySearchLeftEdge(int[] nums, int target) { + // target의 삽입 위치를 찾는 것과 같다 + int i = binary_search_insertion.binarySearchInsertion(nums, target); + // target을 찾지 못했으므로 -1을 반환한다 + if (i == nums.length || nums[i] != target) { + return -1; + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i; + } + ``` + +=== "C#" + + ```csharp title="binary_search_edge.cs" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + int BinarySearchLeftEdge(int[] nums, int target) { + // target의 삽입 위치를 찾는 것과 같다 + int i = binary_search_insertion.BinarySearchInsertion(nums, target); + // target을 찾지 못했으므로 -1을 반환한다 + if (i == nums.Length || nums[i] != target) { + return -1; + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i; + } + ``` + +=== "Go" + + ```go title="binary_search_edge.go" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + func binarySearchLeftEdge(nums []int, target int) int { + // target의 삽입 위치를 찾는 것과 같다 + i := binarySearchInsertion(nums, target) + // target을 찾지 못했으므로 -1을 반환한다 + if i == len(nums) || nums[i] != target { + return -1 + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i + } + ``` + +=== "Swift" + + ```swift title="binary_search_edge.swift" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + func binarySearchLeftEdge(nums: [Int], target: Int) -> Int { + // target의 삽입 위치를 찾는 것과 같다 + let i = binarySearchInsertion(nums: nums, target: target) + // target을 찾지 못했으므로 -1을 반환한다 + if i == nums.endIndex || nums[i] != target { + return -1 + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i + } + ``` + +=== "JS" + + ```javascript title="binary_search_edge.js" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + function binarySearchLeftEdge(nums, target) { + // target의 삽입 위치를 찾는 것과 같다 + const i = binarySearchInsertion(nums, target); + // target을 찾지 못했으므로 -1을 반환한다 + if (i === nums.length || nums[i] !== target) { + return -1; + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i; + } + ``` + +=== "TS" + + ```typescript title="binary_search_edge.ts" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + function binarySearchLeftEdge(nums: Array, target: number): number { + // target의 삽입 위치를 찾는 것과 같다 + const i = binarySearchInsertion(nums, target); + // target을 찾지 못했으므로 -1을 반환한다 + if (i === nums.length || nums[i] !== target) { + return -1; + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i; + } + ``` + +=== "Dart" + + ```dart title="binary_search_edge.dart" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + int binarySearchLeftEdge(List nums, int target) { + // target의 삽입 위치를 찾는 것과 같다 + int i = binarySearchInsertion(nums, target); + // target을 찾지 못했으므로 -1을 반환한다 + if (i == nums.length || nums[i] != target) { + return -1; + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i; + } + ``` + +=== "Rust" + + ```rust title="binary_search_edge.rs" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + fn binary_search_left_edge(nums: &[i32], target: i32) -> i32 { + // target의 삽입 위치를 찾는 것과 같다 + let i = binary_search_insertion(nums, target); + // target을 찾지 못했으므로 -1을 반환한다 + if i == nums.len() as i32 || nums[i as usize] != target { + return -1; + } + // target을 찾았으므로 인덱스 i를 반환한다 + i + } + ``` + +=== "C" + + ```c title="binary_search_edge.c" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + int binarySearchLeftEdge(int *nums, int numSize, int target) { + // target의 삽입 위치를 찾는 것과 같다 + int i = binarySearchInsertion(nums, numSize, target); + // target을 찾지 못했으므로 -1을 반환한다 + if (i == numSize || nums[i] != target) { + return -1; + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i; + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search_edge.kt" + /* 가장 왼쪽에 있는 target을 이진 탐색 */ + fun binarySearchLeftEdge(nums: IntArray, target: Int): Int { + // target의 삽입 위치를 찾는 것과 같다 + val i = binarySearchInsertion(nums, target) + // target을 찾지 못했으므로 -1을 반환한다 + if (i == nums.size || nums[i] != target) { + return -1 + } + // target을 찾았으므로 인덱스 i를 반환한다 + return i + } + ``` + +=== "Ruby" + + ```ruby title="binary_search_edge.rb" + ### 가장 왼쪽에 있는 target을 이진 탐색 ### + def binary_search_left_edge(nums, target) + # target의 삽입 위치를 찾는 것과 같다 + i = binary_search_insertion(nums, target) + + # target을 찾지 못했으므로 -1을 반환한다 + return -1 if i == nums.length || nums[i] != target + + i # target을 찾았으므로 인덱스 i를 반환한다 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 10.3.2   오른쪽 경계 찾기 + +가장 오른쪽에 있는 `target`은 어떻게 찾을까? 가장 직접적인 방법은 코드를 수정하여 `nums[m] == target`일 때 포인터로 구간을 줄이는 방식을 바꾸는 것이다. 코드는 생략하니, 관심 있는 독자는 직접 구현해 보자. + +아래에서는 더 기발한 두 가지 방법을 소개한다. + +### 1.   왼쪽 경계 찾기 재활용 + +가장 왼쪽 원소를 찾는 함수로 가장 오른쪽 원소도 찾을 수 있다. 구체적으로는 **가장 오른쪽 `target`을 찾는 문제를 가장 왼쪽 `target + 1`을 찾는 문제로 바꾼다**. + +아래 그림(10-7)과 같이 탐색이 끝나면 포인터 $i$는 가장 왼쪽 `target + 1`(존재한다면)을 가리키고, $j$는 가장 오른쪽 `target`을 가리킨다. **따라서 $j$를 반환하면 된다**. + +![오른쪽 경계 찾기를 왼쪽 경계 찾기로 변환](binary_search_edge.assets/binary_search_right_edge_by_left_edge.png){ class="animation-figure" } + +

그림 10-7   오른쪽 경계 찾기를 왼쪽 경계 찾기로 변환

+ +반환되는 삽입 위치는 $i$이므로, 여기서 $1$을 빼야 $j$를 얻을 수 있다. + +=== "Python" + + ```python title="binary_search_edge.py" + def binary_search_right_edge(nums: list[int], target: int) -> int: + """가장 오른쪽에 있는 target을 이진 탐색""" + # 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + i = binary_search_insertion(nums, target + 1) + # j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + j = i - 1 + # target을 찾지 못했으므로 -1을 반환한다 + if j == -1 or nums[j] != target: + return -1 + # target을 찾았으므로 인덱스 j를 반환한다 + return j + ``` + +=== "C++" + + ```cpp title="binary_search_edge.cpp" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + int binarySearchRightEdge(vector &nums, int target) { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + int i = binarySearchInsertion(nums, target + 1); + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + int j = i - 1; + // target을 찾지 못했으므로 -1을 반환한다 + if (j == -1 || nums[j] != target) { + return -1; + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j; + } + ``` + +=== "Java" + + ```java title="binary_search_edge.java" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + int binarySearchRightEdge(int[] nums, int target) { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + int i = binary_search_insertion.binarySearchInsertion(nums, target + 1); + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + int j = i - 1; + // target을 찾지 못했으므로 -1을 반환한다 + if (j == -1 || nums[j] != target) { + return -1; + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j; + } + ``` + +=== "C#" + + ```csharp title="binary_search_edge.cs" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + int BinarySearchRightEdge(int[] nums, int target) { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + int i = binary_search_insertion.BinarySearchInsertion(nums, target + 1); + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + int j = i - 1; + // target을 찾지 못했으므로 -1을 반환한다 + if (j == -1 || nums[j] != target) { + return -1; + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j; + } + ``` + +=== "Go" + + ```go title="binary_search_edge.go" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + func binarySearchRightEdge(nums []int, target int) int { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + i := binarySearchInsertion(nums, target+1) + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + j := i - 1 + // target을 찾지 못했으므로 -1을 반환한다 + if j == -1 || nums[j] != target { + return -1 + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j + } + ``` + +=== "Swift" + + ```swift title="binary_search_edge.swift" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + func binarySearchRightEdge(nums: [Int], target: Int) -> Int { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + let i = binarySearchInsertion(nums: nums, target: target + 1) + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + let j = i - 1 + // target을 찾지 못했으므로 -1을 반환한다 + if j == -1 || nums[j] != target { + return -1 + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j + } + ``` + +=== "JS" + + ```javascript title="binary_search_edge.js" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + function binarySearchRightEdge(nums, target) { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + const i = binarySearchInsertion(nums, target + 1); + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + const j = i - 1; + // target을 찾지 못했으므로 -1을 반환한다 + if (j === -1 || nums[j] !== target) { + return -1; + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j; + } + ``` + +=== "TS" + + ```typescript title="binary_search_edge.ts" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + function binarySearchRightEdge(nums: Array, target: number): number { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + const i = binarySearchInsertion(nums, target + 1); + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + const j = i - 1; + // target을 찾지 못했으므로 -1을 반환한다 + if (j === -1 || nums[j] !== target) { + return -1; + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j; + } + ``` + +=== "Dart" + + ```dart title="binary_search_edge.dart" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + int binarySearchRightEdge(List nums, int target) { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + int i = binarySearchInsertion(nums, target + 1); + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + int j = i - 1; + // target을 찾지 못했으므로 -1을 반환한다 + if (j == -1 || nums[j] != target) { + return -1; + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j; + } + ``` + +=== "Rust" + + ```rust title="binary_search_edge.rs" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + fn binary_search_right_edge(nums: &[i32], target: i32) -> i32 { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + let i = binary_search_insertion(nums, target + 1); + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + let j = i - 1; + // target을 찾지 못했으므로 -1을 반환한다 + if j == -1 || nums[j as usize] != target { + return -1; + } + // target을 찾았으므로 인덱스 j를 반환한다 + j + } + ``` + +=== "C" + + ```c title="binary_search_edge.c" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + int binarySearchRightEdge(int *nums, int numSize, int target) { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + int i = binarySearchInsertion(nums, numSize, target + 1); + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + int j = i - 1; + // target을 찾지 못했으므로 -1을 반환한다 + if (j == -1 || nums[j] != target) { + return -1; + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j; + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search_edge.kt" + /* 가장 오른쪽에 있는 target을 이진 탐색 */ + fun binarySearchRightEdge(nums: IntArray, target: Int): Int { + // 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + val i = binarySearchInsertion(nums, target + 1) + // j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + val j = i - 1 + // target을 찾지 못했으므로 -1을 반환한다 + if (j == -1 || nums[j] != target) { + return -1 + } + // target을 찾았으므로 인덱스 j를 반환한다 + return j + } + ``` + +=== "Ruby" + + ```ruby title="binary_search_edge.rb" + ### 가장 오른쪽에 있는 target을 이진 탐색 ### + def binary_search_right_edge(nums, target) + # 가장 왼쪽에 있는 target + 1을 찾는 문제로 바꾼다 + i = binary_search_insertion(nums, target + 1) + + # j는 가장 오른쪽의 target을 가리키고, i는 target보다 큰 첫 번째 원소를 가리킨다 + j = i - 1 + + # target을 찾지 못했으므로 -1을 반환한다 + return -1 if j == -1 || nums[j] != target + + j # target을 찾았으므로 인덱스 j를 반환한다 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 2.   원소 찾기로 변환 + +배열에 `target`이 없으면 최종적으로 $i$는 `target`보다 큰 첫 번째 원소를, $j$는 `target`보다 작은 가장 오른쪽 원소를 가리킨다. + +따라서 아래 그림(10-8)과 같이 배열에 없는 원소를 만들어 왼쪽 경계와 오른쪽 경계를 찾을 수 있다. + +- 가장 왼쪽 `target` 찾기: `target - 0.5`를 탐색하고 포인터 $i$를 반환하는 것으로 바꿀 수 있다. +- 가장 오른쪽 `target` 찾기: `target + 0.5`를 탐색하고 포인터 $j$를 반환하는 것으로 바꿀 수 있다. + +![경계 찾기를 원소 찾기로 변환](binary_search_edge.assets/binary_search_edge_by_element.png){ class="animation-figure" } + +

그림 10-8   경계 찾기를 원소 찾기로 변환

+ +코드는 생략하며, 다음 두 가지에 주의하자. + +- 주어진 배열에는 소수가 없으므로 값이 같은 경우의 처리는 고려하지 않아도 된다. +- 이 방법은 소수를 사용하므로 함수의 변수 `target`을 부동 소수점 타입으로 바꿔야 한다(Python은 수정할 필요가 없다). diff --git a/ko/docs/chapter_searching/binary_search_insertion.md b/ko/docs/chapter_searching/binary_search_insertion.md new file mode 100644 index 000000000..c70e53e70 --- /dev/null +++ b/ko/docs/chapter_searching/binary_search_insertion.md @@ -0,0 +1,674 @@ +--- +comments: true +--- + +# 10.2   이진 탐색으로 삽입 위치 찾기 + +이진 탐색은 목표 원소를 찾는 데만 쓰이는 것이 아니다. 목표 원소를 삽입할 위치를 찾는 등 여러 변형 문제에도 사용할 수 있다. + +## 10.2.1   중복 원소가 없는 경우 + +!!! question "문제" + + 중복 원소가 없는 길이 $n$의 정렬된 배열 `nums`와 원소 `target`이 주어진다. 정렬 상태를 유지하면서 `target`을 배열 `nums`에 삽입하려고 한다. 이미 `target`이 있으면 그 왼쪽에 삽입한다. 삽입 후 `target`의 배열 인덱스를 반환하라. 예시는 아래 그림(10-4)과 같다. + +![이진 탐색으로 삽입 위치를 찾는 예시 데이터](binary_search_insertion.assets/binary_search_insertion_example.png){ class="animation-figure" } + +

그림 10-4   이진 탐색으로 삽입 위치를 찾는 예시 데이터

+ +앞 절의 이진 탐색 코드를 재사용하려면 다음 두 질문에 답해야 한다. + +**질문 1**: 배열에 `target`이 있을 때 삽입 위치의 인덱스는 해당 원소의 인덱스인가? + +문제에서는 값이 같은 원소의 왼쪽에 `target`을 삽입하라고 한다. 따라서 새로 삽입하는 `target`이 기존 `target`의 자리를 차지한다. 즉, **배열에 `target`이 있으면 삽입 위치의 인덱스는 그 `target`의 인덱스다**. + +**질문 2**: 배열에 `target`이 없을 때 삽입 위치는 어느 원소의 인덱스인가? + +이진 탐색 과정을 더 살펴보자. `nums[m] < target`이면 $i$가 이동하므로 포인터 $i$는 `target` 이상인 원소에 가까워진다. 마찬가지로 포인터 $j$는 항상 `target` 이하인 원소에 가까워진다. + +따라서 이진 탐색이 끝나면 $i$는 `target`보다 큰 첫 번째 원소를, $j$는 `target`보다 작은 가장 오른쪽 원소를 가리킨다. **배열에 `target`이 없을 때 삽입 인덱스가 $i$임을 쉽게 알 수 있다**. 코드는 다음과 같다. + +=== "Python" + + ```python title="binary_search_insertion.py" + def binary_search_insertion_simple(nums: list[int], target: int) -> int: + """삽입 위치의 이진 탐색(중복 원소 없음)""" + i, j = 0, len(nums) - 1 # 닫힌 구간 [0, n-1]을 초기화한다 + while i <= j: + m = (i + j) // 2 # 중간 인덱스 m을 계산한다 + if nums[m] < target: + i = m + 1 # target은 구간 [m+1, j]에 있다 + elif nums[m] > target: + j = m - 1 # target은 구간 [i, m-1]에 있다 + else: + return m # target을 찾았으므로 삽입 위치 m을 반환한다 + # target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i + ``` + +=== "C++" + + ```cpp title="binary_search_insertion.cpp" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + int binarySearchInsertionSimple(vector &nums, int target) { + int i = 0, j = nums.size() - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + return m; // target을 찾았으므로 삽입 위치 m을 반환한다 + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Java" + + ```java title="binary_search_insertion.java" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + int binarySearchInsertionSimple(int[] nums, int target) { + int i = 0, j = nums.length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + return m; // target을 찾았으므로 삽입 위치 m을 반환한다 + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "C#" + + ```csharp title="binary_search_insertion.cs" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + int BinarySearchInsertionSimple(int[] nums, int target) { + int i = 0, j = nums.Length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + return m; // target을 찾았으므로 삽입 위치 m을 반환한다 + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Go" + + ```go title="binary_search_insertion.go" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + func binarySearchInsertionSimple(nums []int, target int) int { + // 닫힌 구간 [0, n-1]을 초기화한다 + i, j := 0, len(nums)-1 + for i <= j { + // 중간 인덱스 m을 계산한다 + m := i + (j-i)/2 + if nums[m] < target { + // target은 구간 [m+1, j]에 있다 + i = m + 1 + } else if nums[m] > target { + // target은 구간 [i, m-1]에 있다 + j = m - 1 + } else { + // target을 찾았으므로 삽입 위치 m을 반환한다 + return m + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i + } + ``` + +=== "Swift" + + ```swift title="binary_search_insertion.swift" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + func binarySearchInsertionSimple(nums: [Int], target: Int) -> Int { + // 닫힌 구간 [0, n-1]을 초기화한다 + var i = nums.startIndex + var j = nums.endIndex - 1 + while i <= j { + let m = i + (j - i) / 2 // 중간 인덱스 m을 계산한다 + if nums[m] < target { + i = m + 1 // target은 구간 [m+1, j]에 있다 + } else if nums[m] > target { + j = m - 1 // target은 구간 [i, m-1]에 있다 + } else { + return m // target을 찾았으므로 삽입 위치 m을 반환한다 + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i + } + ``` + +=== "JS" + + ```javascript title="binary_search_insertion.js" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + function binarySearchInsertionSimple(nums, target) { + let i = 0, + j = nums.length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + const m = Math.floor(i + (j - i) / 2); // 중간 인덱스 m을 계산하며, Math.floor()로 내림한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + return m; // target을 찾았으므로 삽입 위치 m을 반환한다 + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "TS" + + ```typescript title="binary_search_insertion.ts" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + function binarySearchInsertionSimple( + nums: Array, + target: number + ): number { + let i = 0, + j = nums.length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + const m = Math.floor(i + (j - i) / 2); // 중간 인덱스 m을 계산하며, Math.floor()로 내림한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + return m; // target을 찾았으므로 삽입 위치 m을 반환한다 + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Dart" + + ```dart title="binary_search_insertion.dart" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + int binarySearchInsertionSimple(List nums, int target) { + int i = 0, j = nums.length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) ~/ 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + return m; // target을 찾았으므로 삽입 위치 m을 반환한다 + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Rust" + + ```rust title="binary_search_insertion.rs" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + fn binary_search_insertion_simple(nums: &[i32], target: i32) -> i32 { + let (mut i, mut j) = (0, nums.len() as i32 - 1); // 닫힌 구간 [0, n-1]을 초기화한다 + while i <= j { + let m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if nums[m as usize] < target { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if nums[m as usize] > target { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + return m; + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + i + } + ``` + +=== "C" + + ```c title="binary_search_insertion.c" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + int binarySearchInsertionSimple(int *nums, int numSize, int target) { + int i = 0, j = numSize - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + return m; // target을 찾았으므로 삽입 위치 m을 반환한다 + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search_insertion.kt" + /* 삽입 위치의 이진 탐색(중복 원소 없음) */ + fun binarySearchInsertionSimple(nums: IntArray, target: Int): Int { + var i = 0 + var j = nums.size - 1 // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + val m = i + (j - i) / 2 // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1 // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1 // target은 구간 [i, m-1]에 있다 + } else { + return m // target을 찾았으므로 삽입 위치 m을 반환한다 + } + } + // target을 찾지 못했으므로 삽입 위치 i를 반환한다 + return i + } + ``` + +=== "Ruby" + + ```ruby title="binary_search_insertion.rb" + ### 삽입 위치의 이진 탐색(중복 원소 없음) ### + def binary_search_insertion_simple(nums, target) + # 닫힌 구간 [0, n-1]을 초기화한다 + i, j = 0, nums.length - 1 + + while i <= j + # 중간 인덱스 m을 계산한다 + m = (i + j) / 2 + + if nums[m] < target + i = m + 1 # target은 구간 [m+1, j]에 있다 + elsif nums[m] > target + j = m - 1 # target은 구간 [i, m-1]에 있다 + else + return m # target을 찾았으므로 삽입 위치 m을 반환한다 + end + end + + i # target을 찾지 못했으므로 삽입 위치 i를 반환한다 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 10.2.2   중복 원소가 있는 경우 + +!!! question "문제" + + 앞 문제에서 배열에 중복 원소가 있을 수 있다는 조건만 추가하고, 나머지 조건은 그대로 둔다. + +배열에 `target`이 여러 개 있다면 일반적인 이진 탐색으로는 그중 하나의 인덱스만 구할 수 있다. **그 원소의 왼쪽과 오른쪽에 `target`이 몇 개 더 있는지는 알 수 없다**. + +문제에서는 목표 원소를 가장 왼쪽에 삽입하라고 한다. **따라서 배열에서 가장 왼쪽 `target`의 인덱스를 찾아야 한다**. 먼저 아래 그림(10-5)과 같은 방법을 생각해 볼 수 있다. + +1. 이진 탐색으로 임의의 `target`의 인덱스를 구하고 이를 $k$라고 한다. +2. 인덱스 $k$부터 왼쪽으로 선형 순회하여 가장 왼쪽 `target`을 찾으면 반환한다. + +![선형 탐색으로 중복 원소의 삽입 위치 찾기](binary_search_insertion.assets/binary_search_insertion_naive.png){ class="animation-figure" } + +

그림 10-5   선형 탐색으로 중복 원소의 삽입 위치 찾기

+ +이 방법도 가능하지만 선형 탐색이 포함되어 시간 복잡도가 $O(n)$이다. 배열에 중복된 `target`이 많으면 매우 비효율적이다. + +이제 이진 탐색 코드를 확장해 보자. 아래 그림(10-6)과 같이 전체 흐름은 그대로 유지한다. 매 반복에서 중간 인덱스 $m$을 계산하고, `target`과 `nums[m]`의 대소 관계에 따라 다음과 같이 처리한다. + +- `nums[m] < target` 또는 `nums[m] > target`이면 아직 `target`을 찾지 못했으므로, 일반적인 이진 탐색처럼 구간을 줄여 **포인터 $i$와 $j$가 `target`에 가까워지게 한다**. +- `nums[m] == target`이면 `target`보다 작은 원소는 구간 $[i, m - 1]$에 있으므로, $j = m - 1$로 구간을 줄여 **포인터 $j$가 `target`보다 작은 원소에 가까워지게 한다**. + +반복이 끝나면 $i$는 가장 왼쪽 `target`을, $j$는 `target`보다 작은 가장 오른쪽 원소를 가리킨다. **따라서 인덱스 $i$가 삽입 위치다**. + +=== "<1>" + ![이진 탐색으로 중복 원소의 삽입 위치를 찾는 단계](binary_search_insertion.assets/binary_search_insertion_step1.png){ class="animation-figure" } + +=== "<2>" + ![삽입 위치 이진 탐색 2단계](binary_search_insertion.assets/binary_search_insertion_step2.png){ class="animation-figure" } + +=== "<3>" + ![삽입 위치 이진 탐색 3단계](binary_search_insertion.assets/binary_search_insertion_step3.png){ class="animation-figure" } + +=== "<4>" + ![삽입 위치 이진 탐색 4단계](binary_search_insertion.assets/binary_search_insertion_step4.png){ class="animation-figure" } + +=== "<5>" + ![삽입 위치 이진 탐색 5단계](binary_search_insertion.assets/binary_search_insertion_step5.png){ class="animation-figure" } + +=== "<6>" + ![삽입 위치 이진 탐색 6단계](binary_search_insertion.assets/binary_search_insertion_step6.png){ class="animation-figure" } + +=== "<7>" + ![삽입 위치 이진 탐색 7단계](binary_search_insertion.assets/binary_search_insertion_step7.png){ class="animation-figure" } + +=== "<8>" + ![삽입 위치 이진 탐색 8단계](binary_search_insertion.assets/binary_search_insertion_step8.png){ class="animation-figure" } + +

그림 10-6   이진 탐색으로 중복 원소의 삽입 위치를 찾는 단계

+ +아래 코드를 보면 `nums[m] > target`과 `nums[m] == target` 분기에서 수행하는 연산이 같으므로 두 분기를 합칠 수 있다. + +그렇더라도 조건을 나누어 두면 논리가 더 명확하고 가독성이 좋으므로 그대로 둘 수 있다. + +=== "Python" + + ```python title="binary_search_insertion.py" + def binary_search_insertion(nums: list[int], target: int) -> int: + """삽입 위치의 이진 탐색(중복 원소 있음)""" + i, j = 0, len(nums) - 1 # 닫힌 구간 [0, n-1]을 초기화한다 + while i <= j: + m = (i + j) // 2 # 중간 인덱스 m을 계산한다 + if nums[m] < target: + i = m + 1 # target은 구간 [m+1, j]에 있다 + elif nums[m] > target: + j = m - 1 # target은 구간 [i, m-1]에 있다 + else: + j = m - 1 # target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + # 삽입 위치 i를 반환한다 + return i + ``` + +=== "C++" + + ```cpp title="binary_search_insertion.cpp" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + int binarySearchInsertion(vector &nums, int target) { + int i = 0, j = nums.size() - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1; // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Java" + + ```java title="binary_search_insertion.java" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + int binarySearchInsertion(int[] nums, int target) { + int i = 0, j = nums.length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1; // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "C#" + + ```csharp title="binary_search_insertion.cs" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + int BinarySearchInsertion(int[] nums, int target) { + int i = 0, j = nums.Length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1; // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Go" + + ```go title="binary_search_insertion.go" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + func binarySearchInsertion(nums []int, target int) int { + // 닫힌 구간 [0, n-1]을 초기화한다 + i, j := 0, len(nums)-1 + for i <= j { + // 중간 인덱스 m을 계산한다 + m := i + (j-i)/2 + if nums[m] < target { + // target은 구간 [m+1, j]에 있다 + i = m + 1 + } else if nums[m] > target { + // target은 구간 [i, m-1]에 있다 + j = m - 1 + } else { + // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + j = m - 1 + } + } + // 삽입 위치 i를 반환한다 + return i + } + ``` + +=== "Swift" + + ```swift title="binary_search_insertion.swift" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + func binarySearchInsertion(nums: [Int], target: Int) -> Int { + // 닫힌 구간 [0, n-1]을 초기화한다 + var i = nums.startIndex + var j = nums.endIndex - 1 + while i <= j { + let m = i + (j - i) / 2 // 중간 인덱스 m을 계산한다 + if nums[m] < target { + i = m + 1 // target은 구간 [m+1, j]에 있다 + } else if nums[m] > target { + j = m - 1 // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1 // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + return i + } + ``` + +=== "JS" + + ```javascript title="binary_search_insertion.js" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + function binarySearchInsertion(nums, target) { + let i = 0, + j = nums.length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + const m = Math.floor(i + (j - i) / 2); // 중간 인덱스 m을 계산하며, Math.floor()로 내림한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1; // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "TS" + + ```typescript title="binary_search_insertion.ts" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + function binarySearchInsertion(nums: Array, target: number): number { + let i = 0, + j = nums.length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + const m = Math.floor(i + (j - i) / 2); // 중간 인덱스 m을 계산하며, Math.floor()로 내림한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1; // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Dart" + + ```dart title="binary_search_insertion.dart" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + int binarySearchInsertion(List nums, int target) { + int i = 0, j = nums.length - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) ~/ 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1; // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Rust" + + ```rust title="binary_search_insertion.rs" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + pub fn binary_search_insertion(nums: &[i32], target: i32) -> i32 { + let (mut i, mut j) = (0, nums.len() as i32 - 1); // 닫힌 구간 [0, n-1]을 초기화한다 + while i <= j { + let m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if nums[m as usize] < target { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if nums[m as usize] > target { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1; // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + i + } + ``` + +=== "C" + + ```c title="binary_search_insertion.c" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + int binarySearchInsertion(int *nums, int numSize, int target) { + int i = 0, j = numSize - 1; // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + int m = i + (j - i) / 2; // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1; // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1; // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1; // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + return i; + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search_insertion.kt" + /* 삽입 위치의 이진 탐색(중복 원소 있음) */ + fun binarySearchInsertion(nums: IntArray, target: Int): Int { + var i = 0 + var j = nums.size - 1 // 닫힌 구간 [0, n-1]을 초기화한다 + while (i <= j) { + val m = i + (j - i) / 2 // 중간 인덱스 m을 계산한다 + if (nums[m] < target) { + i = m + 1 // target은 구간 [m+1, j]에 있다 + } else if (nums[m] > target) { + j = m - 1 // target은 구간 [i, m-1]에 있다 + } else { + j = m - 1 // target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + } + } + // 삽입 위치 i를 반환한다 + return i + } + ``` + +=== "Ruby" + + ```ruby title="binary_search_insertion.rb" + ### 삽입 위치의 이진 탐색(중복 원소 있음) ### + def binary_search_insertion(nums, target) + # 닫힌 구간 [0, n-1]을 초기화한다 + i, j = 0, nums.length - 1 + + while i <= j + # 중간 인덱스 m을 계산한다 + m = (i + j) / 2 + + if nums[m] < target + i = m + 1 # target은 구간 [m+1, j]에 있다 + elsif nums[m] > target + j = m - 1 # target은 구간 [i, m-1]에 있다 + else + j = m - 1 # target보다 작은 가장 오른쪽 원소는 구간 [i, m-1]에 있다 + end + end + + i # 삽입 위치 i를 반환한다 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +!!! tip "참고" + + 이 절의 코드는 모두 '양끝이 닫힌 구간'을 사용한다. 관심 있는 독자는 '왼쪽은 닫히고 오른쪽은 열린 구간'으로 직접 구현해 보자. + +종합하면 이진 탐색은 포인터 $i$와 $j$에 각각 탐색 목표를 정해 주는 과정이다. 목표는 특정 원소(예: `target`)일 수도 있고, 원소의 범위(예: `target`보다 작은 원소)일 수도 있다. + +구간을 계속 반으로 나누는 동안 포인터 $i$와 $j$는 미리 정한 목표에 점차 가까워진다. 결국 답을 찾거나 경계를 넘어선 뒤 멈춘다. diff --git a/ko/docs/chapter_searching/exercises.md b/ko/docs/chapter_searching/exercises.md new file mode 100644 index 000000000..07aa63704 --- /dev/null +++ b/ko/docs/chapter_searching/exercises.md @@ -0,0 +1,99 @@ +--- +comments: true +--- + +# 10.7   연습 문제 + +## 10.7.1   개념 확인 + +### 1.   이진 탐색은 탐색 구간을 어떻게 줄이는가 + +정렬된 배열 `[2, 5, 8, 12, 16, 23, 38]`에서 16을 찾는다. +양 끝을 포함하는 닫힌 구간 `[i, j]`를 사용하며, 중간 인덱스는 +$m=i+(j-i)/2$다. 나눗셈 결과의 소수 부분은 버린다. + +목표 값을 찾을 때까지 매 단계의 `(i, j, m)`과 중간 원소, +다음 단계에서 구간을 어떻게 줄이는지 적어 보자. + +??? success "참고 답안" + + 각 단계의 탐색 과정은 다음과 같다. + + | 단계 | `(i, j, m)` | 중간 원소 | 다음 동작 | + | --- | --- | --- | --- | + | 1 | `(0, 6, 3)` | 12 | `12 < 16`이므로 `i = 4`로 설정 | + | 2 | `(4, 6, 5)` | 23 | `23 > 16`이므로 `j = 4`로 설정 | + | 3 | `(4, 4, 4)` | 16 | 목표 값을 찾았으므로 인덱스 4를 반환 | + + 배열이 정렬되어 있으므로 중간 값이 목표 값보다 작으면 중간 원소와 그 왼쪽을 제외할 수 있다. + 중간 값이 목표 값보다 크면 중간 원소와 그 오른쪽을 제외할 수 있다. + +### 2.   중복 원소의 왼쪽 경계와 오른쪽 경계 + +배열 `[1, 2, 2, 2, 4, 6]`에서 숫자 2를 찾는다. +한 학생이 이진 탐색으로 인덱스 2에서 목표 값을 찾자마자 반환한 뒤, +“인덱스 2가 숫자 2의 왼쪽 경계다.”라고 말했다. + + + +1. 이 학생의 말이 맞는가? 숫자 2의 왼쪽 경계와 오른쪽 경계는 각각 어디인지 이유와 함께 설명하라. +2. 왼쪽 경계를 찾을 때 중간 원소가 목표 값과 같다면, 다음에는 어느 쪽을 계속 탐색해야 하는가? +3. 오른쪽 경계를 찾을 때는 어느 쪽을 계속 탐색해야 하는가? 탐색 과정 전체를 적지 말고 방향만 설명하라. + +??? success "참고 답안" + + 1. 이 학생의 말은 틀렸다. 2를 하나 찾자마자 반환하면 어떤 2를 찾았다는 것만 보장할 수 있고, 가장 왼쪽이나 가장 오른쪽의 2를 찾았다고 보장할 수는 없다. + 이 문제에서 왼쪽 경계는 인덱스 1, 오른쪽 경계는 인덱스 3이다. + + 2. 왼쪽 경계를 찾을 때는 중간 원소가 2와 같더라도 왼쪽을 계속 탐색해야 한다. + 예를 들어 양 끝을 포함하는 닫힌 구간을 사용하는 경우 `j = m - 1`로 설정한다. + + 3. 오른쪽 경계를 찾을 때는 중간 원소가 2와 같으면 오른쪽을 계속 탐색해야 한다. + 예를 들어 `i = m + 1`로 설정한다. + +### 3.   데이터에 따라 어떤 탐색 방법을 선택해야 하는가 + +다음 세 상황에 알맞은 방법을 ‘선형 탐색, 이진 탐색, 해시 테이블’ 중에서 고르고 이유를 설명하라. + + + +1. 이미 정렬되어 있고 더 이상 변하지 않는 정수 $10^7$개에서 반복해서 값을 찾으며, 다른 자료 구조를 추가로 만들지 않는다. +2. 삽입과 삭제가 빈번한 데이터 집합에서 특정 키가 있는지 반복해서 확인한다. 정렬 상태를 유지할 필요가 없고 범위 탐색도 하지 않는다. +3. 정렬되지 않은 배열에서 특정 값을 한 번만 찾는다. + +??? success "참고 답안" + + 1. 이진 탐색: 데이터가 정렬되어 있고 변하지 않으므로 $O(\log n)$ 시간에 탐색할 수 있으며, 추가 공간도 필요하지 않다. + 2. 해시 테이블: 해시 함수가 키를 각 버킷에 비교적 고르게 분산한다면 삽입, 삭제, 키를 이용한 탐색의 평균 시간 복잡도가 모두 $O(1)$이다. + 3. 처음부터 끝까지 직접 순회: 한 번만 찾는 경우에는 정렬하거나 해시 테이블을 만드는 데에도 먼저 배열 전체를 처리해야 하므로, + 이번 작업의 전체 작업량이 줄어들지 않는다. + + 어떤 방법을 선택할지는 데이터의 정렬 여부, 추가 자료 구조의 허용 여부, 조회 횟수, 수행해야 하는 연산에 따라 달라진다. + +## 10.7.2   프로그래밍 연습 + +### 1.   정렬된 배열의 이진 탐색 + +엄격한 오름차순으로 정렬된 정수 배열 `nums`와 목표 값 `target`이 주어진다. 이진 탐색으로 `target`을 찾아라. 존재하면 배열 인덱스를 반환하고, 존재하지 않으면 -1을 반환하라. + +??? tip "풀이 힌트" + + 1. 초기 구간은 left = 0, right = n - 1이며, 구간이 비어 있지 않을 조건은 left <= right다. + 2. mid = left + (right - left) // 2로 중간 인덱스를 계산한다. + 3. `nums[mid]`가 `target`보다 작으면 왼쪽 경계를 `mid + 1`로 옮긴다. `nums[mid]`가 `target`보다 크면 오른쪽 경계를 `mid - 1`로 옮긴다. 같으면 즉시 반환한다. + +[LeetCode](https://leetcode.com/problems/binary-search/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } + +### 2.   정렬된 배열의 삽입 위치 + +엄격한 오름차순으로 정렬된 정수 배열 `nums`와 목표 값 `target`이 주어진다. + +`target`이 이미 배열에 있으면 그 인덱스를 반환하라. 없으면 `target`을 삽입해도 엄격한 오름차순을 유지할 수 있는 위치를 반환하라. 답은 0일 수도 있고 배열 길이와 같을 수도 있다. 이진 탐색을 사용하라. + +??? tip "풀이 힌트" + + 1. 답은 0일 수도 있고 배열 길이 n일 수도 있다. + 2. 양 끝을 포함하는 닫힌 구간을 사용할 때 `nums[mid]`가 `target`보다 크거나 같으면 `right = mid - 1`로 설정하여 더 왼쪽의 위치를 계속 확인한다. 그렇지 않으면 `left = mid + 1`로 설정한다. + 3. 반복문이 끝나면 left가 삽입 위치다. + +[LeetCode](https://leetcode.com/problems/search-insert-position/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_searching/index.md b/ko/docs/chapter_searching/index.md new file mode 100644 index 000000000..b465e93d3 --- /dev/null +++ b/ko/docs/chapter_searching/index.md @@ -0,0 +1,24 @@ +--- +comments: true +icon: material/text-search +--- + +# 제 10 장   탐색 + +![탐색](../assets/covers/chapter_searching.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 탐색은 미지의 세계로 떠나는 모험이다. 신비로운 공간 구석구석을 모두 돌아봐야 할 수도, 목표를 빠르게 찾아낼 수도 있다. + + 무언가를 찾아 나서는 이 여정에서는 매번 새로운 곳을 살필 때마다 뜻밖의 답을 만날 수 있다. + +## 이 장의 내용 + +- [10.1   이진 탐색](binary_search.md) +- [10.2   이진 탐색으로 삽입 위치 찾기](binary_search_insertion.md) +- [10.3   이진 탐색으로 경계 찾기](binary_search_edge.md) +- [10.4   해시를 이용한 최적화 전략](replace_linear_by_hashing.md) +- [10.5   탐색 알고리즘 다시 보기](searching_algorithm_revisited.md) +- [10.6   요약](summary.md) +- [10.7   연습 문제](exercises.md) diff --git a/ko/docs/chapter_searching/replace_linear_by_hashing.md b/ko/docs/chapter_searching/replace_linear_by_hashing.md new file mode 100644 index 000000000..881377850 --- /dev/null +++ b/ko/docs/chapter_searching/replace_linear_by_hashing.md @@ -0,0 +1,546 @@ +--- +comments: true +--- + +# 10.4   해시를 이용한 최적화 전략 + +알고리즘 문제에서는 **선형 탐색을 해시 탐색으로 바꾸어 시간 복잡도를 줄이는 경우가 많다**. 한 가지 문제를 통해 더 자세히 알아보자. + +!!! question "문제" + + 정수 배열 `nums`와 목표 원소 `target`이 주어진다. 배열에서 '합'이 `target`인 두 원소를 찾아 그 인덱스를 반환하라. 답이 여러 개라면 아무거나 하나를 반환해도 된다. + +## 10.4.1   선형 탐색: 시간을 더 써서 공간 절약 + +가능한 모든 조합을 직접 순회해 보자. 아래 그림(10-9)과 같이 이중 반복문을 사용하여 매번 두 정수의 합이 `target`인지 확인하고, 맞으면 두 인덱스를 반환한다. + +![선형 탐색으로 두 수의 합 구하기](replace_linear_by_hashing.assets/two_sum_brute_force.png){ class="animation-figure" } + +

그림 10-9   선형 탐색으로 두 수의 합 구하기

+ +코드는 다음과 같다. + +=== "Python" + + ```python title="two_sum.py" + def two_sum_brute_force(nums: list[int], target: int) -> list[int]: + """방법 1: 완전 열거""" + # 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for i in range(len(nums) - 1): + for j in range(i + 1, len(nums)): + if nums[i] + nums[j] == target: + return [i, j] + return [] + ``` + +=== "C++" + + ```cpp title="two_sum.cpp" + /* 방법 1: 완전 열거 */ + vector twoSumBruteForce(vector &nums, int target) { + int size = nums.size(); + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for (int i = 0; i < size - 1; i++) { + for (int j = i + 1; j < size; j++) { + if (nums[i] + nums[j] == target) + return {i, j}; + } + } + return {}; + } + ``` + +=== "Java" + + ```java title="two_sum.java" + /* 방법 1: 완전 열거 */ + int[] twoSumBruteForce(int[] nums, int target) { + int size = nums.length; + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for (int i = 0; i < size - 1; i++) { + for (int j = i + 1; j < size; j++) { + if (nums[i] + nums[j] == target) + return new int[] { i, j }; + } + } + return new int[0]; + } + ``` + +=== "C#" + + ```csharp title="two_sum.cs" + /* 방법 1: 완전 열거 */ + int[] TwoSumBruteForce(int[] nums, int target) { + int size = nums.Length; + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for (int i = 0; i < size - 1; i++) { + for (int j = i + 1; j < size; j++) { + if (nums[i] + nums[j] == target) + return [i, j]; + } + } + return []; + } + ``` + +=== "Go" + + ```go title="two_sum.go" + /* 방법 1: 완전 열거 */ + func twoSumBruteForce(nums []int, target int) []int { + size := len(nums) + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for i := 0; i < size-1; i++ { + for j := i + 1; j < size; j++ { + if nums[i]+nums[j] == target { + return []int{i, j} + } + } + } + return nil + } + ``` + +=== "Swift" + + ```swift title="two_sum.swift" + /* 방법 1: 완전 열거 */ + func twoSumBruteForce(nums: [Int], target: Int) -> [Int] { + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for i in nums.indices.dropLast() { + for j in nums.indices.dropFirst(i + 1) { + if nums[i] + nums[j] == target { + return [i, j] + } + } + } + return [0] + } + ``` + +=== "JS" + + ```javascript title="two_sum.js" + /* 방법 1: 완전 열거 */ + function twoSumBruteForce(nums, target) { + const n = nums.length; + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + if (nums[i] + nums[j] === target) { + return [i, j]; + } + } + } + return []; + } + ``` + +=== "TS" + + ```typescript title="two_sum.ts" + /* 방법 1: 완전 열거 */ + function twoSumBruteForce(nums: number[], target: number): number[] { + const n = nums.length; + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + if (nums[i] + nums[j] === target) { + return [i, j]; + } + } + } + return []; + } + ``` + +=== "Dart" + + ```dart title="two_sum.dart" + /* 방법 1: 완전 열거 */ + List twoSumBruteForce(List nums, int target) { + int size = nums.length; + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for (var i = 0; i < size - 1; i++) { + for (var j = i + 1; j < size; j++) { + if (nums[i] + nums[j] == target) return [i, j]; + } + } + return [0]; + } + ``` + +=== "Rust" + + ```rust title="two_sum.rs" + /* 방법 1: 완전 열거 */ + pub fn two_sum_brute_force(nums: &Vec, target: i32) -> Option> { + let size = nums.len(); + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for i in 0..size - 1 { + for j in i + 1..size { + if nums[i] + nums[j] == target { + return Some(vec![i as i32, j as i32]); + } + } + } + None + } + ``` + +=== "C" + + ```c title="two_sum.c" + /* 방법 1: 완전 열거 */ + int *twoSumBruteForce(int *nums, int numsSize, int target, int *returnSize) { + for (int i = 0; i < numsSize; ++i) { + for (int j = i + 1; j < numsSize; ++j) { + if (nums[i] + nums[j] == target) { + int *res = malloc(sizeof(int) * 2); + res[0] = i, res[1] = j; + *returnSize = 2; + return res; + } + } + } + *returnSize = 0; + return NULL; + } + ``` + +=== "Kotlin" + + ```kotlin title="two_sum.kt" + /* 방법 1: 완전 열거 */ + fun twoSumBruteForce(nums: IntArray, target: Int): IntArray { + val size = nums.size + // 이중 반복문이며 시간 복잡도는 O(n^2)이다 + for (i in 0.. + + +이 방법의 시간 복잡도는 $O(n^2)$, 공간 복잡도는 $O(1)$이다. 데이터가 많으면 시간이 매우 오래 걸린다. + +## 10.4.2   해시 탐색: 공간을 더 써서 시간 절약 + +키와 값으로 각각 배열 원소와 그 인덱스를 저장하는 해시 테이블을 사용해 보자. 배열을 순회하면서 매번 아래 그림(10-10)의 단계를 수행한다. + +1. 수 `target - nums[i]`가 해시 테이블에 있는지 확인하고, 있으면 두 원소의 인덱스를 바로 반환한다. +2. `nums[i]`와 인덱스 `i`로 이루어진 키-값 쌍을 해시 테이블에 추가한다. + +=== "<1>" + ![보조 해시 테이블로 두 수의 합 구하기](replace_linear_by_hashing.assets/two_sum_hashtable_step1.png){ class="animation-figure" } + +=== "<2>" + ![해시 테이블로 두 수의 합 구하기 2단계](replace_linear_by_hashing.assets/two_sum_hashtable_step2.png){ class="animation-figure" } + +=== "<3>" + ![해시 테이블로 두 수의 합 구하기 3단계](replace_linear_by_hashing.assets/two_sum_hashtable_step3.png){ class="animation-figure" } + +

그림 10-10   보조 해시 테이블로 두 수의 합 구하기

+ +구현 코드는 다음과 같으며, 단일 반복문만 있으면 된다. + +=== "Python" + + ```python title="two_sum.py" + def two_sum_hash_table(nums: list[int], target: int) -> list[int]: + """방법 2: 보조 해시 테이블""" + # 보조 해시 테이블의 공간 복잡도는 O(n)이다 + dic = {} + # 단일 반복문이며 시간 복잡도는 O(n)이다 + for i in range(len(nums)): + if target - nums[i] in dic: + return [dic[target - nums[i]], i] + dic[nums[i]] = i + return [] + ``` + +=== "C++" + + ```cpp title="two_sum.cpp" + /* 방법 2: 보조 해시 테이블 */ + vector twoSumHashTable(vector &nums, int target) { + int size = nums.size(); + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + unordered_map dic; + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for (int i = 0; i < size; i++) { + if (dic.find(target - nums[i]) != dic.end()) { + return {dic[target - nums[i]], i}; + } + dic.emplace(nums[i], i); + } + return {}; + } + ``` + +=== "Java" + + ```java title="two_sum.java" + /* 방법 2: 보조 해시 테이블 */ + int[] twoSumHashTable(int[] nums, int target) { + int size = nums.length; + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + Map dic = new HashMap<>(); + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for (int i = 0; i < size; i++) { + if (dic.containsKey(target - nums[i])) { + return new int[] { dic.get(target - nums[i]), i }; + } + dic.put(nums[i], i); + } + return new int[0]; + } + ``` + +=== "C#" + + ```csharp title="two_sum.cs" + /* 방법 2: 보조 해시 테이블 */ + int[] TwoSumHashTable(int[] nums, int target) { + int size = nums.Length; + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + Dictionary dic = []; + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for (int i = 0; i < size; i++) { + if (dic.ContainsKey(target - nums[i])) { + return [dic[target - nums[i]], i]; + } + dic.Add(nums[i], i); + } + return []; + } + ``` + +=== "Go" + + ```go title="two_sum.go" + /* 방법 2: 보조 해시 테이블 */ + func twoSumHashTable(nums []int, target int) []int { + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + hashTable := map[int]int{} + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for idx, val := range nums { + if preIdx, ok := hashTable[target-val]; ok { + return []int{preIdx, idx} + } + hashTable[val] = idx + } + return nil + } + ``` + +=== "Swift" + + ```swift title="two_sum.swift" + /* 방법 2: 보조 해시 테이블 */ + func twoSumHashTable(nums: [Int], target: Int) -> [Int] { + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + var dic: [Int: Int] = [:] + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for i in nums.indices { + if let j = dic[target - nums[i]] { + return [j, i] + } + dic[nums[i]] = i + } + return [0] + } + ``` + +=== "JS" + + ```javascript title="two_sum.js" + /* 방법 2: 보조 해시 테이블 */ + function twoSumHashTable(nums, target) { + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + let m = {}; + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for (let i = 0; i < nums.length; i++) { + if (m[target - nums[i]] !== undefined) { + return [m[target - nums[i]], i]; + } else { + m[nums[i]] = i; + } + } + return []; + } + ``` + +=== "TS" + + ```typescript title="two_sum.ts" + /* 방법 2: 보조 해시 테이블 */ + function twoSumHashTable(nums: number[], target: number): number[] { + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + let m: Map = new Map(); + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for (let i = 0; i < nums.length; i++) { + let index = m.get(target - nums[i]); + if (index !== undefined) { + return [index, i]; + } else { + m.set(nums[i], i); + } + } + return []; + } + ``` + +=== "Dart" + + ```dart title="two_sum.dart" + /* 방법 2: 보조 해시 테이블 */ + List twoSumHashTable(List nums, int target) { + int size = nums.length; + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + Map dic = HashMap(); + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for (var i = 0; i < size; i++) { + if (dic.containsKey(target - nums[i])) { + return [dic[target - nums[i]]!, i]; + } + dic.putIfAbsent(nums[i], () => i); + } + return [0]; + } + ``` + +=== "Rust" + + ```rust title="two_sum.rs" + /* 방법 2: 보조 해시 테이블 */ + pub fn two_sum_hash_table(nums: &Vec, target: i32) -> Option> { + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + let mut dic = HashMap::new(); + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for (i, num) in nums.iter().enumerate() { + match dic.get(&(target - num)) { + Some(v) => return Some(vec![*v as i32, i as i32]), + None => dic.insert(num, i as i32), + }; + } + None + } + ``` + +=== "C" + + ```c title="two_sum.c" + /* 해시 테이블 */ + typedef struct { + int key; + int val; + UT_hash_handle hh; // uthash.h를 기반으로 구현한다 + } HashTable; + + /* 해시 테이블 조회 */ + HashTable *find(HashTable *h, int key) { + HashTable *tmp; + HASH_FIND_INT(h, &key, tmp); + return tmp; + } + + /* 해시 테이블에 원소 삽입 */ + void insert(HashTable **h, int key, int val) { + HashTable *t = find(*h, key); + if (t == NULL) { + HashTable *tmp = malloc(sizeof(HashTable)); + tmp->key = key, tmp->val = val; + HASH_ADD_INT(*h, key, tmp); + } else { + t->val = val; + } + } + + /* 방법 2: 보조 해시 테이블 */ + int *twoSumHashTable(int *nums, int numsSize, int target, int *returnSize) { + HashTable *hashtable = NULL; + for (int i = 0; i < numsSize; i++) { + HashTable *t = find(hashtable, target - nums[i]); + if (t != NULL) { + int *res = malloc(sizeof(int) * 2); + res[0] = t->val, res[1] = i; + *returnSize = 2; + return res; + } + insert(&hashtable, nums[i], i); + } + *returnSize = 0; + return NULL; + } + ``` + +=== "Kotlin" + + ```kotlin title="two_sum.kt" + /* 방법 2: 보조 해시 테이블 */ + fun twoSumHashTable(nums: IntArray, target: Int): IntArray { + val size = nums.size + // 보조 해시 테이블의 공간 복잡도는 O(n)이다 + val dic = HashMap() + // 단일 반복문이며 시간 복잡도는 O(n)이다 + for (i in 0.. + + +이 방법은 해시 탐색으로 시간 복잡도를 $O(n^2)$에서 $O(n)$으로 줄여 실행 효율을 크게 높인다. + +해시 테이블을 추가로 유지해야 하므로 공간 복잡도는 $O(n)$이다. **그렇더라도 시간과 공간 효율의 균형이 더 좋으므로 이 문제의 최적 풀이에 해당한다**. diff --git a/ko/docs/chapter_searching/searching_algorithm_revisited.md b/ko/docs/chapter_searching/searching_algorithm_revisited.md new file mode 100644 index 000000000..6cc1263e5 --- /dev/null +++ b/ko/docs/chapter_searching/searching_algorithm_revisited.md @@ -0,0 +1,94 @@ +--- +comments: true +--- + +# 10.5   탐색 알고리즘 다시 보기 + +탐색 알고리즘(searching algorithm)은 배열, 연결 리스트, 트리, 그래프 등의 자료 구조에서 특정 조건을 만족하는 원소 하나 또는 여러 개를 찾는 데 사용한다. + +탐색 알고리즘은 구현 아이디어에 따라 다음 두 종류로 나뉜다. + +- 배열, 연결 리스트, 트리, 그래프의 순회처럼 **자료 구조를 순회하여 목표 원소를 찾는다**. +- 이진 탐색, 해시 탐색, 이진 탐색 트리의 탐색처럼 **데이터의 구성 방식이나 데이터에 대한 사전 정보를 이용하여 원소를 효율적으로 찾는다**. + +이 내용은 모두 앞 장들에서 이미 소개했으므로 탐색 알고리즘은 낯설지 않을 것이다. 이 절에서는 더 체계적인 관점에서 탐색 알고리즘을 다시 살펴본다. + +## 10.5.1   완전 탐색 + +완전 탐색은 자료 구조의 각 원소를 순회하여 목표 원소를 찾는다. + +- '선형 탐색'은 배열이나 연결 리스트 같은 선형 자료 구조에 적합하다. 자료 구조의 한쪽 끝에서 원소를 하나씩 방문하며, 목표 원소를 찾거나 찾지 못한 채 반대쪽 끝에 도달하면 종료한다. +- '너비 우선 탐색'과 '깊이 우선 탐색'은 그래프와 트리의 두 가지 순회 전략이다. 너비 우선 탐색은 시작 노드에서 한 층씩 탐색하며 가까운 노드부터 먼 노드 순서로 방문한다. 깊이 우선 탐색은 시작 노드에서 한 경로를 따라 끝까지 간 뒤 되돌아와 다른 경로를 시도하며, 전체 자료 구조를 순회할 때까지 반복한다. + +완전 탐색은 간단하고 범용성이 좋으며, **데이터를 전처리하거나 추가 자료 구조를 사용할 필요가 없다**. + +하지만 **이러한 알고리즘의 시간 복잡도는 $O(n)$**이다. 여기서 $n$은 원소 수이며, 데이터가 많으면 성능이 떨어진다. + +## 10.5.2   적응형 탐색 + +적응형 탐색은 정렬 여부 같은 데이터 고유의 성질을 이용하여 탐색 과정을 최적화하고, 목표 원소를 더 효율적으로 찾는다. + +- '이진 탐색'은 데이터의 정렬 상태를 이용하여 효율적으로 찾으며, 배열에만 적합하다. +- '해시 탐색'은 해시 테이블로 탐색 데이터와 목표 데이터 사이에 키-값 매핑을 만들어 조회한다. +- '트리 탐색'은 이진 탐색 트리 같은 특정 트리 구조에서 노드 값을 비교하여 탐색 대상에서 빠르게 제외하고 목표 원소를 찾는다. + +이러한 알고리즘은 효율이 높아 **시간 복잡도가 $O(\log n)$, 나아가 $O(1)$에 이를 수 있다**. + +하지만 **이런 알고리즘을 사용하려면 데이터를 전처리해야 하는 경우가 많다**. 예를 들어 이진 탐색은 배열을 미리 정렬해야 하며, 해시 탐색과 트리 탐색은 추가 자료 구조가 필요하다. 이러한 자료 구조를 유지하는 데도 시간과 공간이 추가로 든다. + +!!! tip "참고" + + 적응형 탐색 알고리즘은 조회 알고리즘이라고도 하며, **주로 특정 자료 구조에서 목표 원소를 빠르게 조회하는 데 사용한다**. + +## 10.5.3   탐색 방법 선택 + +크기가 $n$인 데이터 집합이 주어지면 선형 탐색, 이진 탐색, 트리 탐색, 해시 탐색 등 다양한 방법으로 목표 원소를 찾을 수 있다. 각 방법의 동작 원리는 아래 그림(10-11)과 같다. + +![다양한 탐색 전략](searching_algorithm_revisited.assets/searching_algorithms.png){ class="animation-figure" } + +

그림 10-11   다양한 탐색 전략

+ +위 방법들의 연산 효율과 특성은 아래 표(10-1)와 같다. + +

표 10-1   탐색 알고리즘 효율 비교

+ +
+ +| | 선형 탐색 | 이진 탐색 | 트리 탐색 | 해시 탐색 | +| ------------ | -------- | ------------------ | ------------------ | --------------- | +| 원소 탐색 | $O(n)$ | $O(\log n)$ | $O(\log n)$ | $O(1)$ | +| 원소 삽입 | $O(1)$ | $O(n)$ | $O(\log n)$ | $O(1)$ | +| 원소 삭제 | $O(n)$ | $O(n)$ | $O(\log n)$ | $O(1)$ | +| 추가 공간 | $O(1)$ | $O(1)$ | $O(n)$ | $O(n)$ | +| 데이터 전처리 | / | 정렬 $O(n \log n)$ | 트리 구성 $O(n \log n)$ | 해시 테이블 구성 $O(n)$ | +| 데이터 정렬 여부 | 정렬되지 않음 | 정렬됨 | 정렬됨 | 정렬되지 않음 | + +
+ +탐색 알고리즘을 선택할 때는 데이터 규모, 요구되는 탐색 성능, 데이터 조회와 갱신 빈도 등도 고려해야 한다. + +**선형 탐색** + +- 범용성이 좋으며 데이터 전처리가 전혀 필요하지 않다. 데이터를 한 번만 조회한다면 나머지 세 방법은 전처리 시간만으로도 선형 탐색보다 오래 걸린다. +- 데이터 규모가 작을 때 적합하다. 이때는 시간 복잡도가 효율에 미치는 영향이 작다. +- 데이터를 추가로 관리할 필요가 없으므로 데이터 갱신이 잦은 상황에 적합하다. + +**이진 탐색** + +- 데이터가 많을 때 적합하며, 최악의 시간 복잡도가 $O(\log n)$으로 성능이 안정적이다. +- 배열을 저장하려면 연속된 메모리 공간이 필요하므로 데이터가 지나치게 많으면 안 된다. +- 정렬된 배열을 유지하는 비용이 크므로 데이터 추가와 삭제가 잦은 상황에는 적합하지 않다. + +**해시 탐색** + +- 평균 시간 복잡도가 $O(1)$이므로 매우 높은 조회 성능이 필요한 상황에 적합하다. +- 해시 테이블은 데이터의 정렬 상태를 유지할 수 없으므로 정렬된 데이터나 범위 탐색이 필요한 상황에는 적합하지 않다. +- 해시 함수와 해시 충돌 처리 전략에 크게 의존하므로 성능이 저하될 위험이 비교적 크다. +- 충돌을 최대한 줄여 좋은 조회 성능을 내려면 추가 공간이 필요하므로 데이터가 지나치게 많은 경우에는 적합하지 않다. + +**트리 탐색** + +- 트리 노드는 메모리에 분산 저장되므로 대규모 데이터에 적합하다. +- 데이터의 정렬 상태를 유지하거나 범위 탐색을 해야 하는 상황에 적합하다. +- 노드를 계속 추가하고 삭제하다 보면 이진 탐색 트리가 한쪽으로 치우쳐 시간 복잡도가 $O(n)$으로 저하될 수 있다. +- AVL 트리나 레드-블랙 트리를 사용하면 각 연산을 안정적으로 $O(\log n)$에 수행할 수 있지만, 트리의 균형을 유지하는 데 추가 비용이 든다. diff --git a/ko/docs/chapter_searching/summary.md b/ko/docs/chapter_searching/summary.md new file mode 100644 index 000000000..ca0944893 --- /dev/null +++ b/ko/docs/chapter_searching/summary.md @@ -0,0 +1,14 @@ +--- +comments: true +--- + +# 10.6   요약 + +### 1.   핵심 정리 + +- 이진 탐색은 데이터가 정렬되어 있다는 성질을 이용하여 반복할 때마다 탐색 구간을 절반씩 줄인다. 입력 데이터가 정렬되어 있어야 하며, 배열이나 배열 기반 자료 구조에만 적합하다. +- 완전 탐색은 자료 구조를 순회하여 데이터를 찾는다. 선형 탐색은 배열과 연결 리스트에, 너비 우선 탐색과 깊이 우선 탐색은 그래프와 트리에 적합하다. 범용성이 좋고 전처리가 필요하지 않지만, 시간 복잡도가 $O(n)$으로 비교적 높다. +- 해시 탐색, 트리 탐색, 이진 탐색은 특정 자료 구조에서 목표 원소를 빠르게 찾는 효율적인 탐색 방법이다. 시간 복잡도가 $O(\log n)$ 또는 $O(1)$에 이를 만큼 효율적이지만, 보통 추가 자료 구조가 필요하다. +- 실제로는 데이터 규모, 요구되는 탐색 성능, 데이터 조회와 갱신 빈도 등을 구체적으로 분석하여 적합한 탐색 방법을 골라야 한다. +- 선형 탐색은 작거나 자주 갱신되는 데이터에, 이진 탐색은 크고 정렬된 데이터에 적합하다. 해시 탐색은 높은 조회 효율이 필요하면서 범위 조회는 필요 없는 데이터에, 트리 탐색은 순서 유지와 범위 조회가 필요한 대규모 동적 데이터에 적합하다. +- 선형 탐색을 해시 탐색으로 바꾸는 것은 실행 시간을 최적화하는 흔한 전략으로, 시간 복잡도를 $O(n)$에서 $O(1)$로 줄일 수 있다. diff --git a/ko/docs/chapter_sorting/bubble_sort.md b/ko/docs/chapter_sorting/bubble_sort.md new file mode 100644 index 000000000..ea8716a4c --- /dev/null +++ b/ko/docs/chapter_sorting/bubble_sort.md @@ -0,0 +1,607 @@ +--- +comments: true +--- + +# 11.3   버블 정렬 + +버블 정렬(bubble sort)은 인접한 원소를 계속 비교하고 교환하여 정렬한다. 이 과정이 거품이 아래에서 위로 올라오는 모습과 비슷하여 버블 정렬이라는 이름이 붙었다. + +아래 그림(11-4)과 같이 원소 교환으로 거품이 올라오는 과정을 흉내 낼 수 있다. 배열의 맨 왼쪽부터 오른쪽으로 순회하며 인접한 두 원소를 비교하고, '왼쪽 원소 > 오른쪽 원소'이면 둘을 교환한다. 순회를 마치면 가장 큰 원소가 배열의 맨 오른쪽으로 이동한다. + +=== "<1>" + ![원소 교환으로 거품의 이동 구현](bubble_sort.assets/bubble_operation_step1.png){ class="animation-figure" } + +=== "<2>" + ![버블 연산 2단계](bubble_sort.assets/bubble_operation_step2.png){ class="animation-figure" } + +=== "<3>" + ![버블 연산 3단계](bubble_sort.assets/bubble_operation_step3.png){ class="animation-figure" } + +=== "<4>" + ![버블 연산 4단계](bubble_sort.assets/bubble_operation_step4.png){ class="animation-figure" } + +=== "<5>" + ![버블 연산 5단계](bubble_sort.assets/bubble_operation_step5.png){ class="animation-figure" } + +=== "<6>" + ![버블 연산 6단계](bubble_sort.assets/bubble_operation_step6.png){ class="animation-figure" } + +=== "<7>" + ![버블 연산 7단계](bubble_sort.assets/bubble_operation_step7.png){ class="animation-figure" } + +

그림 11-4   원소 교환으로 거품의 이동 구현

+ +## 11.3.1   알고리즘 과정 + +배열의 길이가 $n$일 때 버블 정렬의 단계는 아래 그림(11-5)과 같다. + +1. 먼저 $n$ 개 원소에 '버블 연산'을 수행하여 **배열에서 가장 큰 원소를 올바른 위치로 옮긴다**. +2. 이어서 남은 $n - 1$ 개 원소에 '버블 연산'을 수행하여 **두 번째로 큰 원소를 올바른 위치로 옮긴다**. +3. 같은 과정을 반복하여 $n - 1$ 회의 '버블 연산'을 수행하면 **큰 순서로 $n - 1$ 개 원소가 모두 올바른 위치에 놓인다**. +4. 남은 원소 하나는 반드시 가장 작은 원소이므로 정렬할 필요가 없으며, 배열 정렬이 완료된다. + +![버블 정렬 과정](bubble_sort.assets/bubble_sort_overview.png){ class="animation-figure" } + +

그림 11-5   버블 정렬 과정

+ +예시 코드는 다음과 같다. + +=== "Python" + + ```python title="bubble_sort.py" + def bubble_sort(nums: list[int]): + """버블 정렬""" + n = len(nums) + # 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in range(n - 1, 0, -1): + # 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in range(i): + if nums[j] > nums[j + 1]: + # nums[j]와 nums[j + 1]을 교환한다 + nums[j], nums[j + 1] = nums[j + 1], nums[j] + ``` + +=== "C++" + + ```cpp title="bubble_sort.cpp" + /* 버블 정렬 */ + void bubbleSort(vector &nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.size() - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + // 여기서는 std::swap() 함수를 사용한다 + swap(nums[j], nums[j + 1]); + } + } + } + } + ``` + +=== "Java" + + ```java title="bubble_sort.java" + /* 버블 정렬 */ + void bubbleSort(int[] nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + int tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + } + } + } + } + ``` + +=== "C#" + + ```csharp title="bubble_sort.cs" + /* 버블 정렬 */ + void BubbleSort(int[] nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.Length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + (nums[j + 1], nums[j]) = (nums[j], nums[j + 1]); + } + } + } + } + ``` + +=== "Go" + + ```go title="bubble_sort.go" + /* 버블 정렬 */ + func bubbleSort(nums []int) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i := len(nums) - 1; i > 0; i-- { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j := 0; j < i; j++ { + if nums[j] > nums[j+1] { + // nums[j]와 nums[j + 1]을 교환한다 + nums[j], nums[j+1] = nums[j+1], nums[j] + } + } + } + } + ``` + +=== "Swift" + + ```swift title="bubble_sort.swift" + /* 버블 정렬 */ + func bubbleSort(nums: inout [Int]) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in nums.indices.dropFirst().reversed() { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in 0 ..< i { + if nums[j] > nums[j + 1] { + // nums[j]와 nums[j + 1]을 교환한다 + nums.swapAt(j, j + 1) + } + } + } + } + ``` + +=== "JS" + + ```javascript title="bubble_sort.js" + /* 버블 정렬 */ + function bubbleSort(nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (let i = nums.length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (let j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + let tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + } + } + } + } + ``` + +=== "TS" + + ```typescript title="bubble_sort.ts" + /* 버블 정렬 */ + function bubbleSort(nums: number[]): void { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (let i = nums.length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (let j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + let tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + } + } + } + } + ``` + +=== "Dart" + + ```dart title="bubble_sort.dart" + /* 버블 정렬 */ + void bubbleSort(List nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.length - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + int tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + } + } + } + } + ``` + +=== "Rust" + + ```rust title="bubble_sort.rs" + /* 버블 정렬 */ + fn bubble_sort(nums: &mut [i32]) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in (1..nums.len()).rev() { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in 0..i { + if nums[j] > nums[j + 1] { + // nums[j]와 nums[j + 1]을 교환한다 + nums.swap(j, j + 1); + } + } + } + } + ``` + +=== "C" + + ```c title="bubble_sort.c" + /* 버블 정렬 */ + void bubbleSort(int nums[], int size) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = size - 1; i > 0; i--) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + int temp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = temp; + } + } + } + } + ``` + +=== "Kotlin" + + ```kotlin title="bubble_sort.kt" + /* 버블 정렬 */ + fun bubbleSort(nums: IntArray) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (i in nums.size - 1 downTo 1) { + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (j in 0.. nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + val temp = nums[j] + nums[j] = nums[j + 1] + nums[j + 1] = temp + } + } + } + } + ``` + +=== "Ruby" + + ```ruby title="bubble_sort.rb" + ### 버블 정렬 ### + def bubble_sort(nums) + n = nums.length + # 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in (n - 1).downto(1) + # 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in 0...i + if nums[j] > nums[j + 1] + # nums[j]와 nums[j + 1]을 교환한다 + nums[j], nums[j + 1] = nums[j + 1], nums[j] + end + end + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 11.3.2   효율 최적화 + +어느 회의 '버블 연산'에서 교환이 한 번도 일어나지 않았다면 배열은 이미 정렬되어 있으므로 결과를 바로 반환할 수 있다. 따라서 이 상황을 감지하는 플래그 `flag`를 추가하고, 발생하는 즉시 반환할 수 있다. + +최적화 후에도 버블 정렬의 최악 및 평균 시간 복잡도는 $O(n^2)$이다. 하지만 입력 배열이 완전히 정렬되어 있으면 최선 시간 복잡도인 $O(n)$에 도달한다. + +=== "Python" + + ```python title="bubble_sort.py" + def bubble_sort_with_flag(nums: list[int]): + """버블 정렬(플래그 최적화)""" + n = len(nums) + # 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in range(n - 1, 0, -1): + flag = False # 플래그를 초기화한다 + # 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in range(i): + if nums[j] > nums[j + 1]: + # nums[j]와 nums[j + 1]을 교환한다 + nums[j], nums[j + 1] = nums[j + 1], nums[j] + flag = True # 원소가 교환되었음을 기록한다 + if not flag: + break # 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + ``` + +=== "C++" + + ```cpp title="bubble_sort.cpp" + /* 버블 정렬(플래그 최적화)*/ + void bubbleSortWithFlag(vector &nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.size() - 1; i > 0; i--) { + bool flag = false; // 플래그를 초기화한다 + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + // 여기서는 std::swap() 함수를 사용한다 + swap(nums[j], nums[j + 1]); + flag = true; // 원소가 교환되었음을 기록한다 + } + } + if (!flag) + break; // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + } + } + ``` + +=== "Java" + + ```java title="bubble_sort.java" + /* 버블 정렬(플래그 최적화) */ + void bubbleSortWithFlag(int[] nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.length - 1; i > 0; i--) { + boolean flag = false; // 플래그를 초기화한다 + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + int tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + flag = true; // 원소가 교환되었음을 기록한다 + } + } + if (!flag) + break; // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + } + } + ``` + +=== "C#" + + ```csharp title="bubble_sort.cs" + /* 버블 정렬(플래그 최적화)*/ + void BubbleSortWithFlag(int[] nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.Length - 1; i > 0; i--) { + bool flag = false; // 플래그를 초기화한다 + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + (nums[j + 1], nums[j]) = (nums[j], nums[j + 1]); + flag = true; // 원소가 교환되었음을 기록한다 + } + } + if (!flag) break; // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + } + } + ``` + +=== "Go" + + ```go title="bubble_sort.go" + /* 버블 정렬(플래그 최적화)*/ + func bubbleSortWithFlag(nums []int) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i := len(nums) - 1; i > 0; i-- { + flag := false // 플래그를 초기화한다 + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j := 0; j < i; j++ { + if nums[j] > nums[j+1] { + // nums[j]와 nums[j + 1]을 교환한다 + nums[j], nums[j+1] = nums[j+1], nums[j] + flag = true // 원소가 교환되었음을 기록한다 + } + } + if flag == false { // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + break + } + } + } + ``` + +=== "Swift" + + ```swift title="bubble_sort.swift" + /* 버블 정렬(플래그 최적화)*/ + func bubbleSortWithFlag(nums: inout [Int]) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in nums.indices.dropFirst().reversed() { + var flag = false // 플래그를 초기화한다 + for j in 0 ..< i { + if nums[j] > nums[j + 1] { + // nums[j]와 nums[j + 1]을 교환한다 + nums.swapAt(j, j + 1) + flag = true // 원소가 교환되었음을 기록한다 + } + } + if !flag { // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + break + } + } + } + ``` + +=== "JS" + + ```javascript title="bubble_sort.js" + /* 버블 정렬(플래그 최적화)*/ + function bubbleSortWithFlag(nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (let i = nums.length - 1; i > 0; i--) { + let flag = false; // 플래그를 초기화한다 + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (let j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + let tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + flag = true; // 원소가 교환되었음을 기록한다 + } + } + if (!flag) break; // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + } + } + ``` + +=== "TS" + + ```typescript title="bubble_sort.ts" + /* 버블 정렬(플래그 최적화)*/ + function bubbleSortWithFlag(nums: number[]): void { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (let i = nums.length - 1; i > 0; i--) { + let flag = false; // 플래그를 초기화한다 + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (let j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + let tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + flag = true; // 원소가 교환되었음을 기록한다 + } + } + if (!flag) break; // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + } + } + ``` + +=== "Dart" + + ```dart title="bubble_sort.dart" + /* 버블 정렬(플래그 최적화)*/ + void bubbleSortWithFlag(List nums) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = nums.length - 1; i > 0; i--) { + bool flag = false; // 플래그를 초기화한다 + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + int tmp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = tmp; + flag = true; // 원소가 교환되었음을 기록한다 + } + } + if (!flag) break; // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + } + } + ``` + +=== "Rust" + + ```rust title="bubble_sort.rs" + /* 버블 정렬(플래그 최적화) */ + fn bubble_sort_with_flag(nums: &mut [i32]) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in (1..nums.len()).rev() { + let mut flag = false; // 플래그를 초기화한다 + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in 0..i { + if nums[j] > nums[j + 1] { + // nums[j]와 nums[j + 1]을 교환한다 + nums.swap(j, j + 1); + flag = true; // 원소가 교환되었음을 기록한다 + } + } + if !flag { + break; // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + }; + } + } + ``` + +=== "C" + + ```c title="bubble_sort.c" + /* 버블 정렬(플래그 최적화)*/ + void bubbleSortWithFlag(int nums[], int size) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (int i = size - 1; i > 0; i--) { + bool flag = false; + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (int j = 0; j < i; j++) { + if (nums[j] > nums[j + 1]) { + int temp = nums[j]; + nums[j] = nums[j + 1]; + nums[j + 1] = temp; + flag = true; + } + } + if (!flag) + break; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="bubble_sort.kt" + /* 버블 정렬(플래그 최적화) */ + fun bubbleSortWithFlag(nums: IntArray) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for (i in nums.size - 1 downTo 1) { + var flag = false // 플래그를 초기화한다 + // 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for (j in 0.. nums[j + 1]) { + // nums[j]와 nums[j + 1]을 교환한다 + val temp = nums[j] + nums[j] = nums[j + 1] + nums[j + 1] = temp + flag = true // 원소가 교환되었음을 기록한다 + } + } + if (!flag) break // 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + } + } + ``` + +=== "Ruby" + + ```ruby title="bubble_sort.rb" + ### 버블 정렬(플래그 최적화)### + def bubble_sort_with_flag(nums) + n = nums.length + # 바깥쪽 반복문: 정렬되지 않은 구간은 [0, i]이다 + for i in (n - 1).downto(1) + flag = false # 플래그를 초기화한다 + + # 안쪽 반복문: 정렬되지 않은 구간 [0, i]의 최댓값을 교환하여 이 구간의 맨 오른쪽으로 옮긴다 + for j in 0...i + if nums[j] > nums[j + 1] + # nums[j]와 nums[j + 1]을 교환한다 + nums[j], nums[j + 1] = nums[j + 1], nums[j] + flag = true # 원소가 교환되었음을 기록한다 + end + end + + break unless flag # 이번 버블 과정에서 원소 교환이 없었으므로 바로 종료한다 + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 11.3.3   알고리즘 특성 + +- **시간 복잡도 $O(n^2)$, 적응형 정렬**: 각 회의 '버블 연산'에서 순회하는 배열 길이는 차례로 $n - 1$, $n - 2$, $\dots$, $2$, $1$이며, 합은 $(n - 1) n / 2$이다. `flag`를 도입하여 최적화하면 최선 시간 복잡도가 $O(n)$이 된다. +- **공간 복잡도 $O(1)$, 제자리 정렬**: 포인터 $i$와 $j$는 상수 크기의 추가 공간을 사용한다. +- **안정 정렬**: '버블 연산'에서 값이 같은 원소는 교환하지 않는다. diff --git a/ko/docs/chapter_sorting/bucket_sort.md b/ko/docs/chapter_sorting/bucket_sort.md new file mode 100644 index 000000000..b0e080ee2 --- /dev/null +++ b/ko/docs/chapter_sorting/bucket_sort.md @@ -0,0 +1,471 @@ +--- +comments: true +--- + +# 11.8   버킷 정렬 + +앞에서 살펴본 정렬 알고리즘은 모두 원소의 크기를 비교하여 정렬하는 '비교 기반 정렬 알고리즘'이다. 이 알고리즘들의 최악 시간 복잡도 하한은 $\Omega(n \log n)$이다. 이제 시간 복잡도가 선형에 이를 수 있는 몇 가지 '비교를 사용하지 않는 정렬 알고리즘'을 살펴보자. + +버킷 정렬(bucket sort)은 분할 정복 전략의 대표적인 응용이다. 크기순으로 배열된 여러 버킷을 만들고 각 버킷에 데이터 범위를 대응시킨 뒤, 데이터를 각 버킷에 고르게 분배한다. 이어서 버킷별로 내부 정렬을 수행하고, 마지막으로 버킷 순서대로 모든 데이터를 합친다. + +## 11.8.1   알고리즘 과정 + +길이가 $n$이고 원소가 범위 $[0, 1)$의 부동 소수점 수인 배열을 생각해 보자. 버킷 정렬의 과정은 아래 그림(11-13)과 같다. + +1. 버킷 $k$ 개를 초기화하고 원소 $n$ 개를 이 $k$ 개의 버킷에 분배한다. +2. 각 버킷을 따로 정렬한다(여기서는 프로그래밍 언어의 내장 정렬 함수를 사용한다). +3. 작은 버킷부터 큰 버킷 순서로 결과를 합친다. + +![버킷 정렬 알고리즘 과정](bucket_sort.assets/bucket_sort_overview.png){ class="animation-figure" } + +

그림 11-13   버킷 정렬 알고리즘 과정

+ +코드는 다음과 같다. + +=== "Python" + + ```python title="bucket_sort.py" + def bucket_sort(nums: list[float]): + """버킷 정렬""" + # 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + k = len(nums) // 2 + buckets = [[] for _ in range(k)] + # 1. 배열 원소를 각 버킷에 배정한다 + for num in nums: + # 입력 데이터의 범위는 [0, 1)이며, num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + i = int(num * k) + # num을 버킷 i에 추가한다 + buckets[i].append(num) + # 2. 각 버킷을 정렬한다 + for bucket in buckets: + # 내장 정렬 함수를 사용하며, 다른 정렬 알고리즘으로 대체할 수도 있다 + bucket.sort() + # 3. 버킷을 순회하여 결과를 합친다 + i = 0 + for bucket in buckets: + for num in bucket: + nums[i] = num + i += 1 + ``` + +=== "C++" + + ```cpp title="bucket_sort.cpp" + /* 버킷 정렬 */ + void bucketSort(vector &nums) { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + int k = nums.size() / 2; + vector> buckets(k); + // 1. 배열 원소를 각 버킷에 배정한다 + for (float num : nums) { + // 입력 데이터의 범위는 [0, 1)이며, num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + int i = num * k; + // num을 버킷 bucket_idx에 추가한다 + buckets[i].push_back(num); + } + // 2. 각 버킷을 정렬한다 + for (vector &bucket : buckets) { + // 내장 정렬 함수를 사용하며, 다른 정렬 알고리즘으로 대체할 수도 있다 + sort(bucket.begin(), bucket.end()); + } + // 3. 버킷을 순회하여 결과를 합친다 + int i = 0; + for (vector &bucket : buckets) { + for (float num : bucket) { + nums[i++] = num; + } + } + } + ``` + +=== "Java" + + ```java title="bucket_sort.java" + /* 버킷 정렬 */ + void bucketSort(float[] nums) { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + int k = nums.length / 2; + List> buckets = new ArrayList<>(); + for (int i = 0; i < k; i++) { + buckets.add(new ArrayList<>()); + } + // 1. 배열 원소를 각 버킷에 배정한다 + for (float num : nums) { + // 입력 데이터의 범위는 [0, 1)이며, num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + int i = (int) (num * k); + // num을 버킷 i에 추가한다 + buckets.get(i).add(num); + } + // 2. 각 버킷을 정렬한다 + for (List bucket : buckets) { + // 내장 정렬 함수를 사용하며, 다른 정렬 알고리즘으로 대체할 수도 있다 + Collections.sort(bucket); + } + // 3. 버킷을 순회하여 결과를 합친다 + int i = 0; + for (List bucket : buckets) { + for (float num : bucket) { + nums[i++] = num; + } + } + } + ``` + +=== "C#" + + ```csharp title="bucket_sort.cs" + /* 버킷 정렬 */ + void BucketSort(float[] nums) { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + int k = nums.Length / 2; + List> buckets = []; + for (int i = 0; i < k; i++) { + buckets.Add([]); + } + // 1. 배열 원소를 각 버킷에 배정한다 + foreach (float num in nums) { + // 입력 데이터의 범위는 [0, 1)이며, num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + int i = (int)(num * k); + // num을 버킷 i에 추가한다 + buckets[i].Add(num); + } + // 2. 각 버킷을 정렬한다 + foreach (List bucket in buckets) { + // 내장 정렬 함수를 사용하며, 다른 정렬 알고리즘으로 대체할 수도 있다 + bucket.Sort(); + } + // 3. 버킷을 순회하여 결과를 합친다 + int j = 0; + foreach (List bucket in buckets) { + foreach (float num in bucket) { + nums[j++] = num; + } + } + } + ``` + +=== "Go" + + ```go title="bucket_sort.go" + /* 버킷 정렬 */ + func bucketSort(nums []float64) { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + k := len(nums) / 2 + buckets := make([][]float64, k) + for i := 0; i < k; i++ { + buckets[i] = make([]float64, 0) + } + // 1. 배열 원소를 각 버킷에 배정한다 + for _, num := range nums { + // 입력 데이터의 범위는 [0, 1)이며, num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + i := int(num * float64(k)) + // num을 버킷 i에 추가한다 + buckets[i] = append(buckets[i], num) + } + // 2. 각 버킷을 정렬한다 + for i := 0; i < k; i++ { + // 내장 슬라이스 정렬 함수를 사용하며, 다른 정렬 알고리즘으로 대체할 수도 있다 + sort.Float64s(buckets[i]) + } + // 3. 버킷을 순회하여 결과를 합친다 + i := 0 + for _, bucket := range buckets { + for _, num := range bucket { + nums[i] = num + i++ + } + } + } + ``` + +=== "Swift" + + ```swift title="bucket_sort.swift" + /* 버킷 정렬 */ + func bucketSort(nums: inout [Double]) { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + let k = nums.count / 2 + var buckets = (0 ..< k).map { _ in [Double]() } + // 1. 배열 원소를 각 버킷에 배정한다 + for num in nums { + // 입력 데이터의 범위는 [0, 1)이며, num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + let i = Int(num * Double(k)) + // num을 버킷 i에 추가한다 + buckets[i].append(num) + } + // 2. 각 버킷을 정렬한다 + for i in buckets.indices { + // 내장 정렬 함수를 사용하며, 다른 정렬 알고리즘으로 대체할 수도 있다 + buckets[i].sort() + } + // 3. 버킷을 순회하여 결과를 합친다 + var i = nums.startIndex + for bucket in buckets { + for num in bucket { + nums[i] = num + i += 1 + } + } + } + ``` + +=== "JS" + + ```javascript title="bucket_sort.js" + /* 버킷 정렬 */ + function bucketSort(nums) { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + const k = nums.length / 2; + const buckets = []; + for (let i = 0; i < k; i++) { + buckets.push([]); + } + // 1. 배열 원소를 각 버킷에 배정한다 + for (const num of nums) { + // 입력 데이터의 범위는 [0, 1)이며, num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + const i = Math.floor(num * k); + // num을 버킷 i에 추가한다 + buckets[i].push(num); + } + // 2. 각 버킷을 정렬한다 + for (const bucket of buckets) { + // 내장 정렬 함수를 사용하며, 다른 정렬 알고리즘으로 대체할 수도 있다 + bucket.sort((a, b) => a - b); + } + // 3. 버킷을 순회하여 결과를 합친다 + let i = 0; + for (const bucket of buckets) { + for (const num of bucket) { + nums[i++] = num; + } + } + } + ``` + +=== "TS" + + ```typescript title="bucket_sort.ts" + /* 버킷 정렬 */ + function bucketSort(nums: number[]): void { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + const k = nums.length / 2; + const buckets: number[][] = []; + for (let i = 0; i < k; i++) { + buckets.push([]); + } + // 1. 배열 원소를 각 버킷에 배정한다 + for (const num of nums) { + // 입력 데이터의 범위는 [0, 1)이며, num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + const i = Math.floor(num * k); + // num을 버킷 i에 추가한다 + buckets[i].push(num); + } + // 2. 각 버킷을 정렬한다 + for (const bucket of buckets) { + // 내장 정렬 함수를 사용하며, 다른 정렬 알고리즘으로 대체할 수도 있다 + bucket.sort((a, b) => a - b); + } + // 3. 버킷을 순회하여 결과를 합친다 + let i = 0; + for (const bucket of buckets) { + for (const num of bucket) { + nums[i++] = num; + } + } + } + ``` + +=== "Dart" + + ```dart title="bucket_sort.dart" + /* 버킷 정렬 */ + void bucketSort(List nums) { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + int k = nums.length ~/ 2; + List> buckets = List.generate(k, (index) => []); + + // 1. 배열 원소를 각 버킷에 배정한다 + for (double _num in nums) { + // 입력 데이터의 범위는 [0, 1)이며, _num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + int i = (_num * k).toInt(); + // _num을 버킷 bucket_idx에 추가한다 + buckets[i].add(_num); + } + // 2. 각 버킷을 정렬한다 + for (List bucket in buckets) { + bucket.sort(); + } + // 3. 버킷을 순회하여 결과를 합친다 + int i = 0; + for (List bucket in buckets) { + for (double _num in bucket) { + nums[i++] = _num; + } + } + } + ``` + +=== "Rust" + + ```rust title="bucket_sort.rs" + /* 버킷 정렬 */ + fn bucket_sort(nums: &mut [f64]) { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + let k = nums.len() / 2; + let mut buckets = vec![vec![]; k]; + // 1. 배열 원소를 각 버킷에 배정한다 + for &num in nums.iter() { + // 입력 데이터의 범위는 [0, 1)이며, num * k를 사용하여 인덱스 범위 [0, k-1]에 대응시킨다 + let i = (num * k as f64) as usize; + // num을 버킷 i에 추가한다 + buckets[i].push(num); + } + // 2. 각 버킷을 정렬한다 + for bucket in &mut buckets { + // 내장 정렬 함수를 사용하며, 다른 정렬 알고리즘으로 대체할 수도 있다 + bucket.sort_by(|a, b| a.partial_cmp(b).unwrap()); + } + // 3. 버킷을 순회하여 결과를 합친다 + let mut i = 0; + for bucket in buckets.iter() { + for &num in bucket.iter() { + nums[i] = num; + i += 1; + } + } + } + ``` + +=== "C" + + ```c title="bucket_sort.c" + /* 버킷 정렬 */ + void bucketSort(float nums[], int n) { + int k = n / 2; // k = n/2개의 버킷을 초기화한다 + int *sizes = malloc(k * sizeof(int)); // 각 버킷의 크기를 기록한다 + float **buckets = malloc(k * sizeof(float *)); // 동적 배열의 배열(버킷) + // 각 버킷에 충분한 공간을 미리 할당한다 + for (int i = 0; i < k; ++i) { + buckets[i] = (float *)malloc(n * sizeof(float)); + sizes[i] = 0; + } + // 1. 배열 원소를 각 버킷에 배정한다 + for (int i = 0; i < n; ++i) { + int idx = (int)(nums[i] * k); + buckets[idx][sizes[idx]++] = nums[i]; + } + // 2. 각 버킷을 정렬한다 + for (int i = 0; i < k; ++i) { + qsort(buckets[i], sizes[i], sizeof(float), compare); + } + // 3. 정렬한 버킷을 병합한다 + int idx = 0; + for (int i = 0; i < k; ++i) { + for (int j = 0; j < sizes[i]; ++j) { + nums[idx++] = buckets[i][j]; + } + // 메모리 해제 + free(buckets[i]); + } + } + ``` + +=== "Kotlin" + + ```kotlin title="bucket_sort.kt" + /* 버킷 정렬 */ + fun bucketSort(nums: FloatArray) { + // 버킷 k = n/2개를 초기화하며, 각 버킷에 원소 2개가 배정될 것으로 예상한다 + val k = nums.size / 2 + val buckets = mutableListOf>() + for (i in 0.. + + +## 11.8.2   알고리즘 특성 + +버킷 정렬은 매우 큰 데이터를 처리하는 데 적합하다. 예를 들어 입력에 원소가 100만 개 있고 공간 제약 때문에 시스템 메모리에 모든 데이터를 한 번에 올릴 수 없다고 하자. 이때 데이터를 1000개 버킷으로 나누어 각각 정렬한 다음 결과를 합칠 수 있다. + +- **시간 복잡도 $O(n + k)$**: 원소가 각 버킷에 고르게 분포한다면 버킷당 원소 수는 $\frac{n}{k}$이다. 버킷 하나를 정렬하는 데 $O(\frac{n}{k} \log\frac{n}{k})$ 시간이 걸린다고 하면 모든 버킷의 정렬에는 $O(n \log\frac{n}{k})$ 시간이 걸린다. **버킷 수 $k$가 충분히 크면 시간 복잡도는 $O(n)$에 가까워진다**. 결과를 합칠 때는 모든 버킷과 원소를 순회하므로 $O(n + k)$ 시간이 든다. 최악의 경우 모든 데이터가 하나의 버킷에 들어가며, 그 버킷을 정렬하는 데 $O(n^2)$ 시간이 걸린다. +- **공간 복잡도 $O(n + k)$, 제자리 정렬이 아님**: 버킷 $k$ 개와 총 $n$ 개의 원소를 위한 추가 공간이 필요하다. +- 버킷 정렬의 안정성은 버킷 내부를 정렬하는 알고리즘의 안정성에 달려 있다. + +## 11.8.3   고르게 분배하는 방법 + +버킷 정렬의 시간 복잡도는 이론적으로 $O(n)$에 이를 수 있다. **핵심은 원소를 각 버킷에 고르게 분배하는 것**인데, 실제 데이터는 균일하게 분포하지 않는 경우가 많다. 예를 들어 타오바오의 모든 상품을 가격 범위에 따라 10개 버킷에 고르게 나누고 싶다고 하자. 상품 가격은 불균일하여 100위안 미만의 상품은 매우 많고 1000위안 초과 상품은 매우 적다. 가격 구간을 같은 폭으로 10등분하면 버킷별 상품 수에 큰 차이가 생긴다. + +고르게 분배하려면 먼저 대략적인 경계를 정해 데이터를 버킷 3개로 나눌 수 있다. **분배한 뒤 상품이 많은 버킷을 다시 3개로 나누고, 모든 버킷의 원소 수가 비슷해질 때까지 반복한다**. + +아래 그림(11-14)과 같이 이 방법은 본질적으로 재귀 트리를 만드는 과정이며, 리프 노드의 값을 가능한 한 고르게 만드는 것이 목표다. 반드시 매번 버킷 3개로 나눌 필요는 없으며, 데이터 특성에 맞추어 분할 방식을 유연하게 선택할 수 있다. + +![버킷을 재귀적으로 분할](bucket_sort.assets/scatter_in_buckets_recursively.png){ class="animation-figure" } + +

그림 11-14   버킷을 재귀적으로 분할

+ +상품 가격의 확률 분포를 미리 알고 있다면 **그 분포에 따라 각 버킷의 가격 경계를 설정할 수 있다**. 데이터 분포를 반드시 따로 집계할 필요는 없으며, 데이터 특성에 따라 적절한 확률 모형으로 근사할 수도 있다. + +아래 그림(11-15)에서는 상품 가격이 정규 분포를 따른다고 가정한다. 이를 바탕으로 가격 구간을 적절히 정하면 상품을 각 버킷에 고르게 분배할 수 있다. + +![확률 분포에 따른 버킷 분할](bucket_sort.assets/scatter_in_buckets_distribution.png){ class="animation-figure" } + +

그림 11-15   확률 분포에 따른 버킷 분할

diff --git a/ko/docs/chapter_sorting/counting_sort.md b/ko/docs/chapter_sorting/counting_sort.md new file mode 100644 index 000000000..4dc0256a8 --- /dev/null +++ b/ko/docs/chapter_sorting/counting_sort.md @@ -0,0 +1,897 @@ +--- +comments: true +--- + +# 11.9   계수 정렬 + +계수 정렬(counting sort)은 원소의 개수를 세어 정렬하며, 주로 정수 배열에 사용한다. + +## 11.9.1   간단한 구현 + +먼저 간단한 예를 보자. 길이가 $n$인 배열 `nums`의 모든 원소가 '음이 아닌 정수'라고 하자. 계수 정렬의 전체 과정은 아래 그림(11-16)과 같다. + +1. 배열을 순회하여 가장 큰 수 $m$을 찾고, 길이가 $m + 1$인 보조 배열 `counter`를 만든다. +2. **`counter`를 이용하여 `nums`에서 각 수가 나타나는 횟수를 센다**. `counter[num]`은 수 `num`의 등장 횟수다. 세는 방법은 간단하다. `nums`를 순회하면서 현재 수를 `num`이라고 할 때 `counter[num]`을 $1$씩 늘리면 된다. +3. **`counter`의 인덱스는 본래 순서대로 배열되어 있으므로, 모든 수가 이미 정렬된 셈이다**. 이어서 `counter`를 순회하며 각 수의 등장 횟수만큼 작은 수부터 큰 수 순서로 `nums`에 채우면 된다. + +![계수 정렬 과정](counting_sort.assets/counting_sort_overview.png){ class="animation-figure" } + +

그림 11-16   계수 정렬 과정

+ +코드는 다음과 같다. + +=== "Python" + + ```python title="counting_sort.py" + def counting_sort_naive(nums: list[int]): + """계수 정렬""" + # 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + # 1. 배열의 최댓값 m을 구한다 + m = max(nums) + # 2. 각 숫자의 등장 횟수를 센다 + # counter[num]은 num의 등장 횟수를 나타낸다 + counter = [0] * (m + 1) + for num in nums: + counter[num] += 1 + # 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + i = 0 + for num in range(m + 1): + for _ in range(counter[num]): + nums[i] = num + i += 1 + ``` + +=== "C++" + + ```cpp title="counting_sort.cpp" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + void countingSortNaive(vector &nums) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + for (int num : nums) { + m = max(m, num); + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + vector counter(m + 1, 0); + for (int num : nums) { + counter[num]++; + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + int i = 0; + for (int num = 0; num < m + 1; num++) { + for (int j = 0; j < counter[num]; j++, i++) { + nums[i] = num; + } + } + } + ``` + +=== "Java" + + ```java title="counting_sort.java" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + void countingSortNaive(int[] nums) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + for (int num : nums) { + m = Math.max(m, num); + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + int[] counter = new int[m + 1]; + for (int num : nums) { + counter[num]++; + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + int i = 0; + for (int num = 0; num < m + 1; num++) { + for (int j = 0; j < counter[num]; j++, i++) { + nums[i] = num; + } + } + } + ``` + +=== "C#" + + ```csharp title="counting_sort.cs" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + void CountingSortNaive(int[] nums) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + foreach (int num in nums) { + m = Math.Max(m, num); + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + int[] counter = new int[m + 1]; + foreach (int num in nums) { + counter[num]++; + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + int i = 0; + for (int num = 0; num < m + 1; num++) { + for (int j = 0; j < counter[num]; j++, i++) { + nums[i] = num; + } + } + } + ``` + +=== "Go" + + ```go title="counting_sort.go" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + func countingSortNaive(nums []int) { + // 1. 배열의 최댓값 m을 구한다 + m := 0 + for _, num := range nums { + if num > m { + m = num + } + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + counter := make([]int, m+1) + for _, num := range nums { + counter[num]++ + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + for i, num := 0, 0; num < m+1; num++ { + for j := 0; j < counter[num]; j++ { + nums[i] = num + i++ + } + } + } + ``` + +=== "Swift" + + ```swift title="counting_sort.swift" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + func countingSortNaive(nums: inout [Int]) { + // 1. 배열의 최댓값 m을 구한다 + let m = nums.max()! + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + var counter = Array(repeating: 0, count: m + 1) + for num in nums { + counter[num] += 1 + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + var i = 0 + for num in 0 ..< m + 1 { + for _ in 0 ..< counter[num] { + nums[i] = num + i += 1 + } + } + } + ``` + +=== "JS" + + ```javascript title="counting_sort.js" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + function countingSortNaive(nums) { + // 1. 배열의 최댓값 m을 구한다 + let m = Math.max(...nums); + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + const counter = new Array(m + 1).fill(0); + for (const num of nums) { + counter[num]++; + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + let i = 0; + for (let num = 0; num < m + 1; num++) { + for (let j = 0; j < counter[num]; j++, i++) { + nums[i] = num; + } + } + } + ``` + +=== "TS" + + ```typescript title="counting_sort.ts" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + function countingSortNaive(nums: number[]): void { + // 1. 배열의 최댓값 m을 구한다 + let m: number = Math.max(...nums); + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + const counter: number[] = new Array(m + 1).fill(0); + for (const num of nums) { + counter[num]++; + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + let i = 0; + for (let num = 0; num < m + 1; num++) { + for (let j = 0; j < counter[num]; j++, i++) { + nums[i] = num; + } + } + } + ``` + +=== "Dart" + + ```dart title="counting_sort.dart" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + void countingSortNaive(List nums) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + for (int _num in nums) { + m = max(m, _num); + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[_num]은 _num의 등장 횟수를 나타낸다 + List counter = List.filled(m + 1, 0); + for (int _num in nums) { + counter[_num]++; + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + int i = 0; + for (int _num = 0; _num < m + 1; _num++) { + for (int j = 0; j < counter[_num]; j++, i++) { + nums[i] = _num; + } + } + } + ``` + +=== "Rust" + + ```rust title="counting_sort.rs" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + fn counting_sort_naive(nums: &mut [i32]) { + // 1. 배열의 최댓값 m을 구한다 + let m = *nums.iter().max().unwrap(); + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + let mut counter = vec![0; m as usize + 1]; + for &num in nums.iter() { + counter[num as usize] += 1; + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + let mut i = 0; + for num in 0..m + 1 { + for _ in 0..counter[num as usize] { + nums[i] = num; + i += 1; + } + } + } + ``` + +=== "C" + + ```c title="counting_sort.c" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + void countingSortNaive(int nums[], int size) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + for (int i = 0; i < size; i++) { + if (nums[i] > m) { + m = nums[i]; + } + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + int *counter = calloc(m + 1, sizeof(int)); + for (int i = 0; i < size; i++) { + counter[nums[i]]++; + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + int i = 0; + for (int num = 0; num < m + 1; num++) { + for (int j = 0; j < counter[num]; j++, i++) { + nums[i] = num; + } + } + // 4. 메모리를 해제한다 + free(counter); + } + ``` + +=== "Kotlin" + + ```kotlin title="counting_sort.kt" + /* 계수 정렬 */ + // 간단한 구현이며, 객체 정렬에는 사용할 수 없다 + fun countingSortNaive(nums: IntArray) { + // 1. 배열의 최댓값 m을 구한다 + var m = 0 + for (num in nums) { + m = max(m, num) + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + val counter = IntArray(m + 1) + for (num in nums) { + counter[num]++ + } + // 3. counter를 순회하며 각 원소를 원래 배열 nums에 채운다 + var i = 0 + for (num in 0.. + + +!!! note "계수 정렬과 버킷 정렬의 관계" + + 버킷 정렬의 관점에서는 계수 배열 `counter`의 각 인덱스를 하나의 버킷으로, 개수를 세는 과정을 원소를 해당 버킷에 분배하는 과정으로 볼 수 있다. 본질적으로 계수 정렬은 정수 데이터에 적용되는 버킷 정렬의 특수한 경우다. + +## 11.9.2   완전한 구현 + +주의 깊게 읽었다면 **입력 데이터가 객체일 때 위의 `3.` 단계가 작동하지 않는다는 것**을 알아차렸을 것이다. 입력이 상품 객체이고 상품 가격(클래스의 멤버 변수)에 따라 정렬하려 한다고 하자. 위 알고리즘으로는 가격의 정렬 결과만 얻을 수 있다. + +그렇다면 원래 데이터의 정렬 결과는 어떻게 얻을까? 먼저 `counter`의 '접두 합'을 계산한다. 이름 그대로 인덱스 `i`의 접두 합 `prefix[i]`는 배열의 앞 `i` 개 원소를 더한 값이다. + +$$ +\text{prefix}[i] = \sum_{j=0}^i \text{counter[j]} +$$ + +**접두 합은 명확한 의미를 가진다. `prefix[num] - 1`은 결과 배열 `res`에서 원소 `num`이 마지막으로 나타나는 인덱스다**. 이는 각 원소가 결과 배열의 어디에 놓여야 하는지 알려 주는 핵심 정보다. 이어서 원래 배열 `nums`의 각 원소 `num`을 역순으로 순회하며 매번 다음 두 단계를 수행한다. + +1. 배열 `res`의 인덱스 `prefix[num] - 1`에 `num`을 넣는다. +2. 접두 합 `prefix[num]`을 $1$ 줄여 다음에 `num`을 넣을 인덱스를 얻는다. + +순회를 마치면 배열 `res`에 정렬된 결과가 들어 있다. 마지막으로 `res`를 원래 배열 `nums`에 덮어쓰면 된다. 아래 그림(11-17)은 계수 정렬의 전체 과정을 보여 준다. + +=== "<1>" + ![계수 정렬 단계](counting_sort.assets/counting_sort_step1.png){ class="animation-figure" } + +=== "<2>" + ![계수 정렬 2단계](counting_sort.assets/counting_sort_step2.png){ class="animation-figure" } + +=== "<3>" + ![계수 정렬 3단계](counting_sort.assets/counting_sort_step3.png){ class="animation-figure" } + +=== "<4>" + ![계수 정렬 4단계](counting_sort.assets/counting_sort_step4.png){ class="animation-figure" } + +=== "<5>" + ![계수 정렬 5단계](counting_sort.assets/counting_sort_step5.png){ class="animation-figure" } + +=== "<6>" + ![계수 정렬 6단계](counting_sort.assets/counting_sort_step6.png){ class="animation-figure" } + +=== "<7>" + ![계수 정렬 7단계](counting_sort.assets/counting_sort_step7.png){ class="animation-figure" } + +=== "<8>" + ![계수 정렬 8단계](counting_sort.assets/counting_sort_step8.png){ class="animation-figure" } + +

그림 11-17   계수 정렬 단계

+ +계수 정렬의 구현 코드는 다음과 같다. + +=== "Python" + + ```python title="counting_sort.py" + def counting_sort(nums: list[int]): + """계수 정렬""" + # 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + # 1. 배열의 최댓값 m을 구한다 + m = max(nums) + # 2. 각 숫자의 등장 횟수를 센다 + # counter[num]은 num의 등장 횟수를 나타낸다 + counter = [0] * (m + 1) + for num in nums: + counter[num] += 1 + # 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + # 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for i in range(m): + counter[i + 1] += counter[i] + # 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + # 결과를 기록할 배열 res를 초기화한다 + n = len(nums) + res = [0] * n + for i in range(n - 1, -1, -1): + num = nums[i] + res[counter[num] - 1] = num # num을 해당 인덱스에 배치한다 + counter[num] -= 1 # 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + # 결과 배열 res로 원래 배열 nums를 덮어쓴다 + for i in range(n): + nums[i] = res[i] + ``` + +=== "C++" + + ```cpp title="counting_sort.cpp" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + void countingSort(vector &nums) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + for (int num : nums) { + m = max(m, num); + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + vector counter(m + 1, 0); + for (int num : nums) { + counter[num]++; + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for (int i = 0; i < m; i++) { + counter[i + 1] += counter[i]; + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + int n = nums.size(); + vector res(n); + for (int i = n - 1; i >= 0; i--) { + int num = nums[i]; + res[counter[num] - 1] = num; // num을 해당 인덱스에 배치한다 + counter[num]--; // 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + nums = res; + } + ``` + +=== "Java" + + ```java title="counting_sort.java" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + void countingSort(int[] nums) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + for (int num : nums) { + m = Math.max(m, num); + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + int[] counter = new int[m + 1]; + for (int num : nums) { + counter[num]++; + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for (int i = 0; i < m; i++) { + counter[i + 1] += counter[i]; + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + int n = nums.length; + int[] res = new int[n]; + for (int i = n - 1; i >= 0; i--) { + int num = nums[i]; + res[counter[num] - 1] = num; // num을 해당 인덱스에 배치한다 + counter[num]--; // 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + for (int i = 0; i < n; i++) { + nums[i] = res[i]; + } + } + ``` + +=== "C#" + + ```csharp title="counting_sort.cs" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + void CountingSort(int[] nums) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + foreach (int num in nums) { + m = Math.Max(m, num); + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + int[] counter = new int[m + 1]; + foreach (int num in nums) { + counter[num]++; + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for (int i = 0; i < m; i++) { + counter[i + 1] += counter[i]; + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + int n = nums.Length; + int[] res = new int[n]; + for (int i = n - 1; i >= 0; i--) { + int num = nums[i]; + res[counter[num] - 1] = num; // num을 해당 인덱스에 배치한다 + counter[num]--; // 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + for (int i = 0; i < n; i++) { + nums[i] = res[i]; + } + } + ``` + +=== "Go" + + ```go title="counting_sort.go" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + func countingSort(nums []int) { + // 1. 배열의 최댓값 m을 구한다 + m := 0 + for _, num := range nums { + if num > m { + m = num + } + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + counter := make([]int, m+1) + for _, num := range nums { + counter[num]++ + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for i := 0; i < m; i++ { + counter[i+1] += counter[i] + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + n := len(nums) + res := make([]int, n) + for i := n - 1; i >= 0; i-- { + num := nums[i] + // num을 해당 인덱스에 배치한다 + res[counter[num]-1] = num + // 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + counter[num]-- + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + copy(nums, res) + } + ``` + +=== "Swift" + + ```swift title="counting_sort.swift" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + func countingSort(nums: inout [Int]) { + // 1. 배열의 최댓값 m을 구한다 + let m = nums.max()! + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + var counter = Array(repeating: 0, count: m + 1) + for num in nums { + counter[num] += 1 + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for i in 0 ..< m { + counter[i + 1] += counter[i] + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + var res = Array(repeating: 0, count: nums.count) + for i in nums.indices.reversed() { + let num = nums[i] + res[counter[num] - 1] = num // num을 해당 인덱스에 배치한다 + counter[num] -= 1 // 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + for i in nums.indices { + nums[i] = res[i] + } + } + ``` + +=== "JS" + + ```javascript title="counting_sort.js" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + function countingSort(nums) { + // 1. 배열의 최댓값 m을 구한다 + let m = Math.max(...nums); + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + const counter = new Array(m + 1).fill(0); + for (const num of nums) { + counter[num]++; + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for (let i = 0; i < m; i++) { + counter[i + 1] += counter[i]; + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + const n = nums.length; + const res = new Array(n); + for (let i = n - 1; i >= 0; i--) { + const num = nums[i]; + res[counter[num] - 1] = num; // num을 해당 인덱스에 배치한다 + counter[num]--; // 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + for (let i = 0; i < n; i++) { + nums[i] = res[i]; + } + } + ``` + +=== "TS" + + ```typescript title="counting_sort.ts" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + function countingSort(nums: number[]): void { + // 1. 배열의 최댓값 m을 구한다 + let m: number = Math.max(...nums); + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + const counter: number[] = new Array(m + 1).fill(0); + for (const num of nums) { + counter[num]++; + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for (let i = 0; i < m; i++) { + counter[i + 1] += counter[i]; + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + const n = nums.length; + const res: number[] = new Array(n); + for (let i = n - 1; i >= 0; i--) { + const num = nums[i]; + res[counter[num] - 1] = num; // num을 해당 인덱스에 배치한다 + counter[num]--; // 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + for (let i = 0; i < n; i++) { + nums[i] = res[i]; + } + } + ``` + +=== "Dart" + + ```dart title="counting_sort.dart" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + void countingSort(List nums) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + for (int _num in nums) { + m = max(m, _num); + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[_num]은 _num의 등장 횟수를 나타낸다 + List counter = List.filled(m + 1, 0); + for (int _num in nums) { + counter[_num]++; + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[_num]-1은 res에서 _num이 마지막으로 등장하는 인덱스이다 + for (int i = 0; i < m; i++) { + counter[i + 1] += counter[i]; + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + int n = nums.length; + List res = List.filled(n, 0); + for (int i = n - 1; i >= 0; i--) { + int _num = nums[i]; + res[counter[_num] - 1] = _num; // _num을 해당 인덱스에 배치한다 + counter[_num]--; // 접두 합을 1 감소시켜 다음에 _num을 배치할 인덱스를 구한다 + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + nums.setAll(0, res); + } + ``` + +=== "Rust" + + ```rust title="counting_sort.rs" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + fn counting_sort(nums: &mut [i32]) { + // 1. 배열의 최댓값 m을 구한다 + let m = *nums.iter().max().unwrap() as usize; + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + let mut counter = vec![0; m + 1]; + for &num in nums.iter() { + counter[num as usize] += 1; + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for i in 0..m { + counter[i + 1] += counter[i]; + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + let n = nums.len(); + let mut res = vec![0; n]; + for i in (0..n).rev() { + let num = nums[i]; + res[counter[num as usize] - 1] = num; // num을 해당 인덱스에 배치한다 + counter[num as usize] -= 1; // 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + nums.copy_from_slice(&res) + } + ``` + +=== "C" + + ```c title="counting_sort.c" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + void countingSort(int nums[], int size) { + // 1. 배열의 최댓값 m을 구한다 + int m = 0; + for (int i = 0; i < size; i++) { + if (nums[i] > m) { + m = nums[i]; + } + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + int *counter = calloc(m, sizeof(int)); + for (int i = 0; i < size; i++) { + counter[nums[i]]++; + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for (int i = 0; i < m; i++) { + counter[i + 1] += counter[i]; + } + // 4. nums를 역순으로 순회하며 각 원소를 결과 배열 res에 채운다 + // 결과를 기록할 배열 res를 초기화한다 + int *res = malloc(sizeof(int) * size); + for (int i = size - 1; i >= 0; i--) { + int num = nums[i]; + res[counter[num] - 1] = num; // num을 해당 인덱스에 배치한다 + counter[num]--; // 접두 합을 1 감소시켜 다음에 num을 배치할 인덱스를 구한다 + } + // 결과 배열 res로 원래 배열 nums를 덮어쓴다 + memcpy(nums, res, size * sizeof(int)); + // 5. 메모리를 해제한다 + free(res); + free(counter); + } + ``` + +=== "Kotlin" + + ```kotlin title="counting_sort.kt" + /* 계수 정렬 */ + // 완전한 구현이며, 객체를 정렬할 수 있고 안정 정렬이다 + fun countingSort(nums: IntArray) { + // 1. 배열의 최댓값 m을 구한다 + var m = 0 + for (num in nums) { + m = max(m, num) + } + // 2. 각 숫자의 등장 횟수를 센다 + // counter[num]은 num의 등장 횟수를 나타낸다 + val counter = IntArray(m + 1) + for (num in nums) { + counter[num]++ + } + // 3. counter의 접두 합을 구하여 등장 횟수를 마지막 인덱스로 변환한다 + // 즉, counter[num]-1은 res에서 num이 마지막으로 등장하는 인덱스이다 + for (i in 0.. + + +## 11.9.3   알고리즘 특성 + +- **시간 복잡도 $O(n + m)$, 비적응형 정렬**: `nums`와 `counter`를 순회하며, 각각 선형 시간이 걸린다. 보통 $n \gg m$이므로 시간 복잡도는 $O(n)$에 가까워진다. +- **공간 복잡도 $O(n + m)$, 제자리 정렬이 아님**: 길이가 각각 $n$과 $m$인 배열 `res`와 `counter`를 사용한다. +- **안정 정렬**: `res`에는 '오른쪽에서 왼쪽으로' 원소를 채우므로, `nums`를 역순으로 순회하면 값이 같은 원소의 상대적 위치가 바뀌지 않아 안정 정렬이 된다. 정방향으로 순회해도 정렬 결과 자체는 올바르지만 안정적이지 않다. + +## 11.9.4   한계 + +여기까지 보면 계수 정렬이 매우 기발하다고 느낄 수 있다. 개수만 세어도 효율적으로 정렬할 수 있기 때문이다. 하지만 계수 정렬을 사용하기 위한 전제 조건은 비교적 엄격하다. + +**계수 정렬은 음이 아닌 정수에만 적합하다**. 다른 타입에 적용하려면 해당 데이터를 음이 아닌 정수로 바꿀 수 있어야 하며, 변환 과정에서 원소 간 대소 관계가 바뀌면 안 된다. 예를 들어 음수가 포함된 정수 배열은 모든 수에 일정한 상수를 더해 양수로 바꾼 뒤, 정렬 후 다시 원래 값으로 돌릴 수 있다. + +**계수 정렬은 데이터 수는 많지만 값의 범위는 작은 경우에 적합하다**. 위 예시에서 $m$이 너무 크면 공간을 지나치게 많이 차지한다. 또한 $n \ll m$이면 계수 정렬에 $O(m)$ 시간이 걸리므로 $O(n \log n)$ 정렬 알고리즘보다 느릴 수도 있다. diff --git a/ko/docs/chapter_sorting/exercises.md b/ko/docs/chapter_sorting/exercises.md new file mode 100644 index 000000000..34daeafbe --- /dev/null +++ b/ko/docs/chapter_sorting/exercises.md @@ -0,0 +1,98 @@ +--- +comments: true +--- + +# 11.12   연습 문제 + +## 11.12.1   개념 확인 + +### 1.   선택 정렬과 버블 정렬의 처음 몇 회전 + +배열 `[4, 2, 5, 1, 3]`이 주어진다. 다음은 모두 오름차순으로 정렬한다. + + + +1. 선택 정렬의 첫 두 회전을 따라가며 각 회전이 끝난 뒤의 배열을 적고, 어느 위치가 확정되었는지 설명하라. +2. 버블 정렬의 첫 회전을 따라가며 배열 상태와 교환 횟수를 적고, 어느 위치가 확정되었는지 설명하라. + +??? success "참고 답안" + + 1. 첫 두 회전은 다음과 같다. + + | 회전 | 배열 상태 | 설명 | + | --- | --- | --- | + | 1 | `[1, 2, 5, 4, 3]` | 가장 작은 원소 1을 첫 번째 원소와 교환 | + | 2 | `[1, 2, 5, 4, 3]` | 값 2가 이미 인덱스 1에 있으므로 교환할 필요 없음 | + + 이때 앞의 두 위치는 확정되었으므로, 이후에는 `[5, 4, 3]`에서 계속 최솟값을 선택하면 된다. + + 2. 이웃한 원소를 차례대로 비교한다. 4와 2는 교환하고, 4와 5는 교환하지 않으며, 5와 1을 교환한 뒤 5와 3을 교환한다. + 결과는 `[2, 4, 1, 3, 5]`이고 총 3번 교환한다. 가장 큰 원소 5가 배열 끝으로 이동했으므로 마지막 위치가 확정되었다. + +### 2.   값이 같은 원소의 순서가 달라지는가 + +배열 $[2_a, 2_b, 1]$에서 $2_a$와 $2_b$는 값이 같지만, 아래 첨자로 원래 순서를 표시했다. + + + +1. 선택 정렬의 첫 회전이 끝난 뒤의 배열을 적어 보자. $2_a$와 $2_b$의 상대적인 순서가 바뀌었는가? +2. 버블 정렬의 첫 회전이 끝난 뒤의 배열을 적어 보자. $2_a$와 $2_b$의 상대적인 순서가 바뀌었는가? +3. 앞의 두 결과를 바탕으로, 값이 같은 원소의 원래 순서를 유지하는 측면에서 두 정렬이 어떻게 다른지 설명하라. + +??? success "참고 답안" + + 1. 선택 정렬의 첫 회전에서는 가장 작은 원소 1을 골라 첫 원소 $2_a$와 교환한다. 결과는 + $[1, 2_b, 2_a]$다. $2_a$가 $2_b$ 뒤로 옮겨졌으므로 두 원소의 상대적인 순서가 바뀌었다. + + 2. 버블 정렬은 먼저 $2_a$와 $2_b$를 비교한다. 두 값이 같으므로 교환하지 않는다. 이어서 $2_b$와 1을 비교하여 교환한다. + 첫 회전이 끝나면 $[2_a, 1, 2_b]$가 된다. $2_a$가 여전히 $2_b$ 앞에 있으므로 상대적인 순서는 바뀌지 않았다. + + 3. 이 예에서 선택 정렬은 값이 같은 원소의 원래 순서를 바꾼다. 버블 정렬은 왼쪽 원소가 오른쪽 원소보다 클 때만 + 이웃한 원소를 교환한다. 값이 같은 원소끼리는 교환하지 않으므로 원래의 앞뒤 순서를 유지할 수 있다. + +### 3.   계수 정렬과 기수 정렬 비교하기 + +학교에서 자릿수가 8자리로 고정된 학번을 대량으로 정렬하려 한다. 다음에 답하라. + + + +1. 가장 낮은 자리부터 처리하는 기수 정렬은 총 몇 회전이 필요한가? +2. 학번을 그대로 정수로 취급하여 계수 정렬을 하면, 사용하지 않는 계수 배열의 자리를 많이 마련하게 되는 이유는 무엇인가? +3. 앞의 두 결과를 바탕으로, 8자리로 고정된 학번을 대량으로 처리하려면 계수 정렬과 기수 정렬 중 어느 것을 선택하겠는가? 이유도 설명하라. + +??? success "참고 답안" + + 1. 학번은 8자리이므로 가장 낮은 자리부터 가장 높은 자리까지 총 8회전이 필요하다. 매 회전에서는 0~9에 따라 그룹을 나누기만 하면 된다. + + 2. 학번 전체의 출현 횟수를 직접 세려면 가능한 모든 8자리 값에 대해 자리를 마련해야 한다. 하지만 실제 학생이 사용하는 학번은 그중 일부에 불과하므로, + 계수 배열의 대부분은 계속 0으로 남는다. + + 3. 기수 정렬을 선택해야 한다. ‘자릿수가 고정되어 있고, 각 자리에 올 수 있는 값은 10가지뿐’이라는 특징을 이용하면 안정적인 그룹 나누기를 8회 반복하는 것으로 충분하다. + 계수 정렬에서 8자리 학번 전체를 정수 인덱스로 사용하면 실제로는 나타나지 않을 수많은 값에 대해서도 계수 배열의 자리를 마련해야 한다. + +## 11.12.2   프로그래밍 연습 + +### 1.   병합 정렬로 배열 정렬하기 + +정수 배열 `nums`가 주어진다. 병합 정렬을 직접 구현하여 원소를 비내림차순으로 정렬한 뒤 반환하라. 언어에서 제공하는 정렬 함수를 호출하지 마라. + +??? tip "풀이 힌트" + + 1. 구간 길이가 1 이하면 이미 정렬되어 있다. + 2. 중간 지점에서 구간을 둘로 나누고 각각 재귀적으로 정렬한다. + 3. 두 포인터로 정렬된 두 구간을 병합한 뒤, 결과를 원래 배열에 다시 쓴다. + +[LeetCode](https://leetcode.com/problems/sort-an-array/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } + +### 2.   계수 정렬로 정수 배열 정렬하기 + +정수 배열 `nums`와 음이 아닌 정수 $K$가 주어진다. 배열의 모든 원소는 $0$ 이상 $K$ 이하이다. + +계수 정렬을 구현하여 비내림차순으로 정렬한 결과를 `nums`에 다시 쓰고 `nums`를 반환하라. +원소의 대소 비교로 순서를 결정하거나 언어에서 제공하는 정렬 함수를 호출하지 마라. + +??? tip "풀이 힌트" + + 1. 모든 원소가 0 이상 K 이하이므로 원소 값을 계수 배열의 인덱스로 바로 사용할 수 있다. + 2. 먼저 nums를 한 번 순회하면서 해당 위치의 계수를 1씩 늘린다. + 3. 이어서 계수 배열을 0부터 K까지 순회한다. 값 x가 나타난 횟수만큼 nums에 x를 연속해서 쓴다. diff --git a/ko/docs/chapter_sorting/heap_sort.md b/ko/docs/chapter_sorting/heap_sort.md new file mode 100644 index 000000000..0ce7fbae4 --- /dev/null +++ b/ko/docs/chapter_sorting/heap_sort.md @@ -0,0 +1,624 @@ +--- +comments: true +--- + +# 11.7   힙 정렬 + +!!! tip "참고" + + 이 절을 읽기 전에 '힙' 장을 먼저 학습하자. + +힙 정렬(heap sort)은 힙 자료 구조를 이용한 효율적인 정렬 알고리즘이다. 이미 배운 '힙 구성 연산'과 '힙에서 원소를 꺼내는 연산'으로 구현할 수 있다. + +1. 배열을 입력받아 최소 힙을 구성한다. 이때 가장 작은 원소가 힙의 루트에 놓인다. +2. 힙에서 원소를 계속 꺼내며 순서대로 기록하면 오름차순으로 정렬된 수열을 얻는다. + +위 방법도 가능하지만 꺼낸 원소를 저장할 추가 배열이 필요하여 공간을 낭비한다. 실제로는 보통 더 간결한 구현 방식을 사용한다. + +## 11.7.1   알고리즘 과정 + +배열의 길이가 $n$일 때 힙 정렬의 과정은 아래 그림(11-12)과 같다. + +1. 배열을 입력받아 최대 힙을 구성한다. 완료하면 가장 큰 원소가 힙의 루트에 놓인다. +2. 힙의 루트 원소(첫 번째 원소)와 끝 원소(마지막 원소)를 교환한다. 교환 후 힙의 길이는 $1$ 줄고, 정렬된 원소 수는 $1$ 늘어난다. +3. 힙의 루트 원소부터 위에서 아래로 힙 재정렬(sift down)을 수행한다. 재정렬을 마치면 힙의 성질이 복구된다. +4. `2.` 단계와 `3.` 단계를 반복한다. $n - 1$ 회 반복하면 배열 정렬이 완료된다. + +!!! tip "참고" + + 힙에서 원소를 꺼내는 연산에도 `2.` 단계와 `3.` 단계가 포함되며, 실제로 원소를 꺼내는 단계가 하나 더 있을 뿐이다. + +=== "<1>" + ![힙 정렬 단계](heap_sort.assets/heap_sort_step1.png){ class="animation-figure" } + +=== "<2>" + ![힙 정렬 2단계](heap_sort.assets/heap_sort_step2.png){ class="animation-figure" } + +=== "<3>" + ![힙 정렬 3단계](heap_sort.assets/heap_sort_step3.png){ class="animation-figure" } + +=== "<4>" + ![힙 정렬 4단계](heap_sort.assets/heap_sort_step4.png){ class="animation-figure" } + +=== "<5>" + ![힙 정렬 5단계](heap_sort.assets/heap_sort_step5.png){ class="animation-figure" } + +=== "<6>" + ![힙 정렬 6단계](heap_sort.assets/heap_sort_step6.png){ class="animation-figure" } + +=== "<7>" + ![힙 정렬 7단계](heap_sort.assets/heap_sort_step7.png){ class="animation-figure" } + +=== "<8>" + ![힙 정렬 8단계](heap_sort.assets/heap_sort_step8.png){ class="animation-figure" } + +=== "<9>" + ![힙 정렬 9단계](heap_sort.assets/heap_sort_step9.png){ class="animation-figure" } + +=== "<10>" + ![힙 정렬 10단계](heap_sort.assets/heap_sort_step10.png){ class="animation-figure" } + +=== "<11>" + ![힙 정렬 11단계](heap_sort.assets/heap_sort_step11.png){ class="animation-figure" } + +=== "<12>" + ![힙 정렬 12단계](heap_sort.assets/heap_sort_step12.png){ class="animation-figure" } + +

그림 11-12   힙 정렬 단계

+ +구현에서는 '힙' 장과 동일한 하향 힙 재정렬 함수 `sift_down()`을 사용한다. 가장 큰 원소를 꺼낼 때마다 힙의 길이가 줄어들므로, 현재 유효한 힙 길이를 지정하는 매개변수 $n$을 `sift_down()`에 추가해야 한다. 코드는 다음과 같다. + +=== "Python" + + ```python title="heap_sort.py" + def sift_down(nums: list[int], n: int, i: int): + """길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬""" + while True: + # 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + l = 2 * i + 1 + r = 2 * i + 2 + ma = i + if l < n and nums[l] > nums[ma]: + ma = l + if r < n and nums[r] > nums[ma]: + ma = r + # 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if ma == i: + break + # 두 노드를 교환한다 + nums[i], nums[ma] = nums[ma], nums[i] + # 아래쪽으로 힙 재정렬을 반복한다 + i = ma + + def heap_sort(nums: list[int]): + """힙 정렬""" + # 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for i in range(len(nums) // 2 - 1, -1, -1): + sift_down(nums, len(nums), i) + # 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for i in range(len(nums) - 1, 0, -1): + # 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + nums[0], nums[i] = nums[i], nums[0] + # 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + sift_down(nums, i, 0) + ``` + +=== "C++" + + ```cpp title="heap_sort.cpp" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + void siftDown(vector &nums, int n, int i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + int l = 2 * i + 1; + int r = 2 * i + 2; + int ma = i; + if (l < n && nums[l] > nums[ma]) + ma = l; + if (r < n && nums[r] > nums[ma]) + ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) { + break; + } + // 두 노드를 교환한다 + swap(nums[i], nums[ma]); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + + /* 힙 정렬 */ + void heapSort(vector &nums) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (int i = nums.size() / 2 - 1; i >= 0; --i) { + siftDown(nums, nums.size(), i); + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for (int i = nums.size() - 1; i > 0; --i) { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + swap(nums[0], nums[i]); + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + siftDown(nums, i, 0); + } + } + ``` + +=== "Java" + + ```java title="heap_sort.java" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + void siftDown(int[] nums, int n, int i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + int l = 2 * i + 1; + int r = 2 * i + 2; + int ma = i; + if (l < n && nums[l] > nums[ma]) + ma = l; + if (r < n && nums[r] > nums[ma]) + ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) + break; + // 두 노드를 교환한다 + int temp = nums[i]; + nums[i] = nums[ma]; + nums[ma] = temp; + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + + /* 힙 정렬 */ + void heapSort(int[] nums) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (int i = nums.length / 2 - 1; i >= 0; i--) { + siftDown(nums, nums.length, i); + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for (int i = nums.length - 1; i > 0; i--) { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + int tmp = nums[0]; + nums[0] = nums[i]; + nums[i] = tmp; + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + siftDown(nums, i, 0); + } + } + ``` + +=== "C#" + + ```csharp title="heap_sort.cs" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + void SiftDown(int[] nums, int n, int i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + int l = 2 * i + 1; + int r = 2 * i + 2; + int ma = i; + if (l < n && nums[l] > nums[ma]) + ma = l; + if (r < n && nums[r] > nums[ma]) + ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) + break; + // 두 노드를 교환한다 + (nums[ma], nums[i]) = (nums[i], nums[ma]); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + + /* 힙 정렬 */ + void HeapSort(int[] nums) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (int i = nums.Length / 2 - 1; i >= 0; i--) { + SiftDown(nums, nums.Length, i); + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for (int i = nums.Length - 1; i > 0; i--) { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + (nums[i], nums[0]) = (nums[0], nums[i]); + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + SiftDown(nums, i, 0); + } + } + ``` + +=== "Go" + + ```go title="heap_sort.go" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + func siftDown(nums *[]int, n, i int) { + for true { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + l := 2*i + 1 + r := 2*i + 2 + ma := i + if l < n && (*nums)[l] > (*nums)[ma] { + ma = l + } + if r < n && (*nums)[r] > (*nums)[ma] { + ma = r + } + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if ma == i { + break + } + // 두 노드를 교환한다 + (*nums)[i], (*nums)[ma] = (*nums)[ma], (*nums)[i] + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma + } + } + + /* 힙 정렬 */ + func heapSort(nums *[]int) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for i := len(*nums)/2 - 1; i >= 0; i-- { + siftDown(nums, len(*nums), i) + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for i := len(*nums) - 1; i > 0; i-- { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + (*nums)[0], (*nums)[i] = (*nums)[i], (*nums)[0] + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + siftDown(nums, i, 0) + } + } + ``` + +=== "Swift" + + ```swift title="heap_sort.swift" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + func siftDown(nums: inout [Int], n: Int, i: Int) { + var i = i + while true { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + let l = 2 * i + 1 + let r = 2 * i + 2 + var ma = i + if l < n, nums[l] > nums[ma] { + ma = l + } + if r < n, nums[r] > nums[ma] { + ma = r + } + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if ma == i { + break + } + // 두 노드를 교환한다 + nums.swapAt(i, ma) + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma + } + } + + /* 힙 정렬 */ + func heapSort(nums: inout [Int]) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for i in stride(from: nums.count / 2 - 1, through: 0, by: -1) { + siftDown(nums: &nums, n: nums.count, i: i) + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for i in nums.indices.dropFirst().reversed() { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + nums.swapAt(0, i) + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + siftDown(nums: &nums, n: i, i: 0) + } + } + ``` + +=== "JS" + + ```javascript title="heap_sort.js" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + function siftDown(nums, n, i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + let l = 2 * i + 1; + let r = 2 * i + 2; + let ma = i; + if (l < n && nums[l] > nums[ma]) { + ma = l; + } + if (r < n && nums[r] > nums[ma]) { + ma = r; + } + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma === i) { + break; + } + // 두 노드를 교환한다 + [nums[i], nums[ma]] = [nums[ma], nums[i]]; + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + + /* 힙 정렬 */ + function heapSort(nums) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (let i = Math.floor(nums.length / 2) - 1; i >= 0; i--) { + siftDown(nums, nums.length, i); + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for (let i = nums.length - 1; i > 0; i--) { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + [nums[0], nums[i]] = [nums[i], nums[0]]; + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + siftDown(nums, i, 0); + } + } + ``` + +=== "TS" + + ```typescript title="heap_sort.ts" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + function siftDown(nums: number[], n: number, i: number): void { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + let l = 2 * i + 1; + let r = 2 * i + 2; + let ma = i; + if (l < n && nums[l] > nums[ma]) { + ma = l; + } + if (r < n && nums[r] > nums[ma]) { + ma = r; + } + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma === i) { + break; + } + // 두 노드를 교환한다 + [nums[i], nums[ma]] = [nums[ma], nums[i]]; + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + + /* 힙 정렬 */ + function heapSort(nums: number[]): void { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (let i = Math.floor(nums.length / 2) - 1; i >= 0; i--) { + siftDown(nums, nums.length, i); + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for (let i = nums.length - 1; i > 0; i--) { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + [nums[0], nums[i]] = [nums[i], nums[0]]; + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + siftDown(nums, i, 0); + } + } + ``` + +=== "Dart" + + ```dart title="heap_sort.dart" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + void siftDown(List nums, int n, int i) { + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + int l = 2 * i + 1; + int r = 2 * i + 2; + int ma = i; + if (l < n && nums[l] > nums[ma]) ma = l; + if (r < n && nums[r] > nums[ma]) ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) break; + // 두 노드를 교환한다 + int temp = nums[i]; + nums[i] = nums[ma]; + nums[ma] = temp; + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + + /* 힙 정렬 */ + void heapSort(List nums) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (int i = nums.length ~/ 2 - 1; i >= 0; i--) { + siftDown(nums, nums.length, i); + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for (int i = nums.length - 1; i > 0; i--) { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + int tmp = nums[0]; + nums[0] = nums[i]; + nums[i] = tmp; + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + siftDown(nums, i, 0); + } + } + ``` + +=== "Rust" + + ```rust title="heap_sort.rs" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + fn sift_down(nums: &mut [i32], n: usize, mut i: usize) { + loop { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + let l = 2 * i + 1; + let r = 2 * i + 2; + let mut ma = i; + if l < n && nums[l] > nums[ma] { + ma = l; + } + if r < n && nums[r] > nums[ma] { + ma = r; + } + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if ma == i { + break; + } + // 두 노드를 교환한다 + nums.swap(i, ma); + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + + /* 힙 정렬 */ + fn heap_sort(nums: &mut [i32]) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for i in (0..nums.len() / 2).rev() { + sift_down(nums, nums.len(), i); + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for i in (1..nums.len()).rev() { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + nums.swap(0, i); + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + sift_down(nums, i, 0); + } + } + ``` + +=== "C" + + ```c title="heap_sort.c" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + void siftDown(int nums[], int n, int i) { + while (1) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + int l = 2 * i + 1; + int r = 2 * i + 2; + int ma = i; + if (l < n && nums[l] > nums[ma]) + ma = l; + if (r < n && nums[r] > nums[ma]) + ma = r; + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) { + break; + } + // 두 노드를 교환한다 + int temp = nums[i]; + nums[i] = nums[ma]; + nums[ma] = temp; + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma; + } + } + + /* 힙 정렬 */ + void heapSort(int nums[], int n) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (int i = n / 2 - 1; i >= 0; --i) { + siftDown(nums, n, i); + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for (int i = n - 1; i > 0; --i) { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + int tmp = nums[0]; + nums[0] = nums[i]; + nums[i] = tmp; + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + siftDown(nums, i, 0); + } + } + ``` + +=== "Kotlin" + + ```kotlin title="heap_sort.kt" + /* 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 */ + fun siftDown(nums: IntArray, n: Int, li: Int) { + var i = li + while (true) { + // 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + val l = 2 * i + 1 + val r = 2 * i + 2 + var ma = i + if (l < n && nums[l] > nums[ma]) + ma = l + if (r < n && nums[r] > nums[ma]) + ma = r + // 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + if (ma == i) + break + // 두 노드를 교환한다 + val temp = nums[i] + nums[i] = nums[ma] + nums[ma] = temp + // 아래쪽으로 힙 재정렬을 반복한다 + i = ma + } + } + + /* 힙 정렬 */ + fun heapSort(nums: IntArray) { + // 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + for (i in nums.size / 2 - 1 downTo 0) { + siftDown(nums, nums.size, i) + } + // 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + for (i in nums.size - 1 downTo 1) { + // 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + val temp = nums[0] + nums[0] = nums[i] + nums[i] = temp + // 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + siftDown(nums, i, 0) + } + } + ``` + +=== "Ruby" + + ```ruby title="heap_sort.rb" + ### 길이가 n인 힙에서 노드 i부터 위에서 아래로 힙 재정렬 ### + def sift_down(nums, n, i) + while true + # 노드 i, l, r 중 값이 가장 큰 노드를 찾아 ma로 표시한다 + l = 2 * i + 1 + r = 2 * i + 2 + ma = i + ma = l if l < n && nums[l] > nums[ma] + ma = r if r < n && nums[r] > nums[ma] + # 노드 i의 값이 가장 크거나 인덱스 l, r이 범위를 벗어나면 힙을 더 재정렬할 필요가 없으므로 종료한다 + break if ma == i + # 두 노드를 교환한다 + nums[i], nums[ma] = nums[ma], nums[i] + # 아래쪽으로 힙 재정렬을 반복한다 + i = ma + end + end + + ### 힙 정렬 ### + def heap_sort(nums) + # 힙 구성 연산: 리프 노드를 제외한 모든 노드에 힙 재정렬을 수행한다 + (nums.length / 2 - 1).downto(0) do |i| + sift_down(nums, nums.length, i) + end + # 힙에서 최댓값을 꺼내는 작업을 n-1회 반복한다 + (nums.length - 1).downto(1) do |i| + # 루트 노드와 가장 오른쪽 리프 노드를 교환한다(첫 원소와 마지막 원소 교환) + nums[0], nums[i] = nums[i], nums[0] + # 루트 노드에서 시작하여 위에서 아래로 힙을 재정렬한다 + sift_down(nums, i, 0) + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 11.7.2   알고리즘 특성 + +- **시간 복잡도 $O(n \log n)$, 비적응형 정렬**: 힙 구성에는 $O(n)$ 시간이 걸린다. 힙에서 최댓값을 꺼내는 시간 복잡도는 $O(\log n)$이며, 총 $n - 1$ 회 반복한다. +- **공간 복잡도 $O(1)$, 제자리 정렬**: 몇 개의 포인터 변수가 $O(1)$ 공간을 사용한다. 원소 교환과 힙 재정렬은 모두 원래 배열에서 수행한다. +- **불안정 정렬**: 힙의 루트 원소와 끝 원소를 교환할 때 값이 같은 원소들의 상대적 위치가 바뀔 수 있다. diff --git a/ko/docs/chapter_sorting/index.md b/ko/docs/chapter_sorting/index.md new file mode 100644 index 000000000..061c46432 --- /dev/null +++ b/ko/docs/chapter_sorting/index.md @@ -0,0 +1,29 @@ +--- +comments: true +icon: material/sort-ascending +--- + +# 제 11 장   정렬 + +![정렬](../assets/covers/chapter_sorting.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 정렬은 혼란을 질서로 바꾸는 마법의 열쇠와 같아서, 데이터를 더 효율적으로 이해하고 처리하게 해 준다. + + 단순한 오름차순이든 복잡한 분류와 배열이든, 정렬은 데이터가 지닌 조화로운 아름다움을 보여 준다. + +## 이 장의 내용 + +- [11.1   정렬 알고리즘](sorting_algorithm.md) +- [11.2   선택 정렬](selection_sort.md) +- [11.3   버블 정렬](bubble_sort.md) +- [11.4   삽입 정렬](insertion_sort.md) +- [11.5   퀵 정렬](quick_sort.md) +- [11.6   병합 정렬](merge_sort.md) +- [11.7   힙 정렬](heap_sort.md) +- [11.8   버킷 정렬](bucket_sort.md) +- [11.9   계수 정렬](counting_sort.md) +- [11.10   기수 정렬](radix_sort.md) +- [11.11   요약](summary.md) +- [11.12   연습 문제](exercises.md) diff --git a/ko/docs/chapter_sorting/insertion_sort.md b/ko/docs/chapter_sorting/insertion_sort.md new file mode 100644 index 000000000..11306c658 --- /dev/null +++ b/ko/docs/chapter_sorting/insertion_sort.md @@ -0,0 +1,296 @@ +--- +comments: true +--- + +# 11.4   삽입 정렬 + +삽입 정렬(insertion sort)은 간단한 정렬 알고리즘으로, 손으로 카드 한 벌을 정리하는 과정과 원리가 매우 비슷하다. + +구체적으로는 미정렬 구간에서 기준 원소 하나를 골라, 왼쪽의 정렬된 구간에 있는 원소들과 하나씩 크기를 비교한 뒤 올바른 위치에 삽입한다. + +아래 그림(11-6)은 배열에 원소를 삽입하는 과정을 보여 준다. 기준 원소를 `base`라고 하면 목표 인덱스부터 `base` 사이의 모든 원소를 오른쪽으로 한 칸씩 이동한 뒤, 목표 인덱스에 `base`를 대입한다. + +![한 번의 삽입 연산](insertion_sort.assets/insertion_operation.png){ class="animation-figure" } + +

그림 11-6   한 번의 삽입 연산

+ +## 11.4.1   알고리즘 과정 + +삽입 정렬의 전체 과정은 아래 그림(11-7)과 같다. + +1. 처음에는 배열의 첫 번째 원소가 정렬되어 있다. +2. 배열의 두 번째 원소를 `base`로 골라 올바른 위치에 삽입하면 **배열의 앞 2개 원소가 정렬된다**. +3. 세 번째 원소를 `base`로 골라 올바른 위치에 삽입하면 **배열의 앞 3개 원소가 정렬된다**. +4. 같은 과정을 반복한다. 마지막 회에서 마지막 원소를 `base`로 골라 올바른 위치에 삽입하면 **모든 원소가 정렬된다**. + +![삽입 정렬 과정](insertion_sort.assets/insertion_sort_overview.png){ class="animation-figure" } + +

그림 11-7   삽입 정렬 과정

+ +예시 코드는 다음과 같다. + +=== "Python" + + ```python title="insertion_sort.py" + def insertion_sort(nums: list[int]): + """삽입 정렬""" + # 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for i in range(1, len(nums)): + base = nums[i] + j = i - 1 + # 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while j >= 0 and nums[j] > base: + nums[j + 1] = nums[j] # nums[j]를 오른쪽으로 한 칸 이동한다 + j -= 1 + nums[j + 1] = base # 올바른 위치에 base를 대입한다 + ``` + +=== "C++" + + ```cpp title="insertion_sort.cpp" + /* 삽입 정렬 */ + void insertionSort(vector &nums) { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for (int i = 1; i < nums.size(); i++) { + int base = nums[i], j = i - 1; + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while (j >= 0 && nums[j] > base) { + nums[j + 1] = nums[j]; // nums[j]를 오른쪽으로 한 칸 이동한다 + j--; + } + nums[j + 1] = base; // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "Java" + + ```java title="insertion_sort.java" + /* 삽입 정렬 */ + void insertionSort(int[] nums) { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for (int i = 1; i < nums.length; i++) { + int base = nums[i], j = i - 1; + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while (j >= 0 && nums[j] > base) { + nums[j + 1] = nums[j]; // nums[j]를 오른쪽으로 한 칸 이동한다 + j--; + } + nums[j + 1] = base; // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "C#" + + ```csharp title="insertion_sort.cs" + /* 삽입 정렬 */ + void InsertionSort(int[] nums) { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for (int i = 1; i < nums.Length; i++) { + int bas = nums[i], j = i - 1; + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while (j >= 0 && nums[j] > bas) { + nums[j + 1] = nums[j]; // nums[j]를 오른쪽으로 한 칸 이동한다 + j--; + } + nums[j + 1] = bas; // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "Go" + + ```go title="insertion_sort.go" + /* 삽입 정렬 */ + func insertionSort(nums []int) { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for i := 1; i < len(nums); i++ { + base := nums[i] + j := i - 1 + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + for j >= 0 && nums[j] > base { + nums[j+1] = nums[j] // nums[j]를 오른쪽으로 한 칸 이동한다 + j-- + } + nums[j+1] = base // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "Swift" + + ```swift title="insertion_sort.swift" + /* 삽입 정렬 */ + func insertionSort(nums: inout [Int]) { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for i in nums.indices.dropFirst() { + let base = nums[i] + var j = i - 1 + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while j >= 0, nums[j] > base { + nums[j + 1] = nums[j] // nums[j]를 오른쪽으로 한 칸 이동한다 + j -= 1 + } + nums[j + 1] = base // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "JS" + + ```javascript title="insertion_sort.js" + /* 삽입 정렬 */ + function insertionSort(nums) { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for (let i = 1; i < nums.length; i++) { + let base = nums[i], + j = i - 1; + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while (j >= 0 && nums[j] > base) { + nums[j + 1] = nums[j]; // nums[j]를 오른쪽으로 한 칸 이동한다 + j--; + } + nums[j + 1] = base; // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "TS" + + ```typescript title="insertion_sort.ts" + /* 삽입 정렬 */ + function insertionSort(nums: number[]): void { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for (let i = 1; i < nums.length; i++) { + const base = nums[i]; + let j = i - 1; + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while (j >= 0 && nums[j] > base) { + nums[j + 1] = nums[j]; // nums[j]를 오른쪽으로 한 칸 이동한다 + j--; + } + nums[j + 1] = base; // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "Dart" + + ```dart title="insertion_sort.dart" + /* 삽입 정렬 */ + void insertionSort(List nums) { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for (int i = 1; i < nums.length; i++) { + int base = nums[i], j = i - 1; + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while (j >= 0 && nums[j] > base) { + nums[j + 1] = nums[j]; // nums[j]를 오른쪽으로 한 칸 이동한다 + j--; + } + nums[j + 1] = base; // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "Rust" + + ```rust title="insertion_sort.rs" + /* 삽입 정렬 */ + fn insertion_sort(nums: &mut [i32]) { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for i in 1..nums.len() { + let (base, mut j) = (nums[i], (i - 1) as i32); + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while j >= 0 && nums[j as usize] > base { + nums[(j + 1) as usize] = nums[j as usize]; // nums[j]를 오른쪽으로 한 칸 이동한다 + j -= 1; + } + nums[(j + 1) as usize] = base; // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "C" + + ```c title="insertion_sort.c" + /* 삽입 정렬 */ + void insertionSort(int nums[], int size) { + // 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for (int i = 1; i < size; i++) { + int base = nums[i], j = i - 1; + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while (j >= 0 && nums[j] > base) { + // nums[j]를 오른쪽으로 한 칸 이동한다 + nums[j + 1] = nums[j]; + j--; + } + // 올바른 위치에 base를 대입한다 + nums[j + 1] = base; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="insertion_sort.kt" + /* 삽입 정렬 */ + fun insertionSort(nums: IntArray) { + //바깥쪽 반복문: 정렬된 원소의 수는 1, 2, ..., n이 된다 + for (i in nums.indices) { + val base = nums[i] + var j = i - 1 + // 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while (j >= 0 && nums[j] > base) { + nums[j + 1] = nums[j] // nums[j]를 오른쪽으로 한 칸 이동한다 + j-- + } + nums[j + 1] = base // 올바른 위치에 base를 대입한다 + } + } + ``` + +=== "Ruby" + + ```ruby title="insertion_sort.rb" + ### 삽입 정렬 ### + def insertion_sort(nums) + n = nums.length + # 바깥쪽 반복문: 정렬된 구간은 [0, i-1]이다 + for i in 1...n + base = nums[i] + j = i - 1 + # 안쪽 반복문: base를 정렬된 구간 [0, i-1]의 올바른 위치에 삽입한다 + while j >= 0 && nums[j] > base + nums[j + 1] = nums[j] # nums[j]를 오른쪽으로 한 칸 이동한다 + j -= 1 + end + nums[j + 1] = base # 올바른 위치에 base를 대입한다 + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 11.4.2   알고리즘 특성 + +- **시간 복잡도 $O(n^2)$, 적응형 정렬**: 최악의 경우 각 삽입 연산에서 $n - 1$, $n-2$, $\dots$, $2$, $1$ 번 반복해야 한다. 합은 $(n - 1) n / 2$이므로 시간 복잡도는 $O(n^2)$이다. 정렬된 데이터를 만나면 삽입 연산이 일찍 끝난다. 입력 배열이 완전히 정렬되어 있으면 최선 시간 복잡도 $O(n)$에 도달한다. +- **공간 복잡도 $O(1)$, 제자리 정렬**: 포인터 $i$와 $j$는 상수 크기의 추가 공간을 사용한다. +- **안정 정렬**: 삽입할 때 값이 같은 원소의 오른쪽에 삽입하므로 상대적 순서가 바뀌지 않는다. + +## 11.4.3   삽입 정렬의 장점 + +삽입 정렬의 시간 복잡도는 $O(n^2)$이고, 곧 배울 퀵 정렬의 시간 복잡도는 $O(n \log n)$이다. 삽입 정렬의 시간 복잡도가 더 높지만 **데이터가 적으면 보통 삽입 정렬이 더 빠르다**. + +이는 선형 탐색과 이진 탐색의 적합한 사용 상황에서 내린 결론과 비슷하다. 퀵 정렬 같은 $O(n \log n)$ 알고리즘은 분할 정복 전략에 기반한 정렬 알고리즘으로, 기본 연산이 더 많은 경우가 많다. 데이터가 적으면 $n^2$과 $n \log n$의 값이 비슷하여 시간 복잡도가 결정적인 요인이 되지 못하고, 매 반복에서 수행하는 기본 연산 수가 결정적인 역할을 한다. + +실제로 Java 등 여러 프로그래밍 언어의 내장 정렬 함수는 삽입 정렬을 활용한다. 대체로 긴 배열에는 퀵 정렬 같은 분할 정복 기반 정렬을 사용하고, 짧은 배열에는 삽입 정렬을 바로 적용한다. + +버블 정렬, 선택 정렬, 삽입 정렬의 시간 복잡도는 모두 $O(n^2)$이지만, 실제로는 **삽입 정렬이 버블 정렬이나 선택 정렬보다 훨씬 자주 사용된다**. 주된 이유는 다음과 같다. + +- 버블 정렬은 원소 교환으로 구현하므로 임시 변수가 필요하고 총 3회의 기본 연산을 수행한다. 삽입 정렬은 원소 대입으로 구현하므로 기본 연산이 1회만 필요하다. 따라서 **버블 정렬의 계산 비용은 보통 삽입 정렬보다 크다**. +- 선택 정렬의 시간 복잡도는 어떤 경우에도 $O(n^2)$이다. **데이터가 부분적으로 정렬되어 있다면 삽입 정렬이 보통 선택 정렬보다 효율적이다**. +- 선택 정렬은 불안정하므로 여러 기준으로 순차 정렬하는 데 사용할 수 없다. diff --git a/ko/docs/chapter_sorting/merge_sort.md b/ko/docs/chapter_sorting/merge_sort.md new file mode 100644 index 000000000..573b76ae3 --- /dev/null +++ b/ko/docs/chapter_sorting/merge_sort.md @@ -0,0 +1,700 @@ +--- +comments: true +--- + +# 11.6   병합 정렬 + +병합 정렬(merge sort)은 분할 정복 전략에 기반한 정렬 알고리즘으로, 아래 그림(11-10)의 '분할' 단계와 '병합' 단계로 이루어진다. + +1. **분할 단계**: 재귀적으로 배열을 계속 중간에서 나누어, 긴 배열을 정렬하는 문제를 짧은 배열을 정렬하는 문제로 바꾼다. +2. **병합 단계**: 부분 배열의 길이가 1이 되면 분할을 멈추고 병합을 시작한다. 정렬된 짧은 왼쪽 배열과 오른쪽 배열을 하나의 더 긴 정렬된 배열로 계속 합치며, 끝날 때까지 반복한다. + +![병합 정렬의 분할 단계와 병합 단계](merge_sort.assets/merge_sort_overview.png){ class="animation-figure" } + +

그림 11-10   병합 정렬의 분할 단계와 병합 단계

+ +## 11.6.1   알고리즘 과정 + +아래 그림(11-11)과 같이 '분할 단계'에서는 위에서 아래로 재귀하며 배열을 중간에서 두 부분 배열로 나눈다. + +1. 배열의 중간 인덱스 `mid`를 계산하고 왼쪽 부분 배열(구간 `[left, mid]`)과 오른쪽 부분 배열(구간 `[mid + 1, right]`)을 재귀적으로 나눈다. +2. 부분 배열 구간의 길이가 1이 될 때까지 `1.` 단계를 재귀적으로 반복한다. + +'병합 단계'에서는 아래에서 위로 왼쪽 부분 배열과 오른쪽 부분 배열을 하나의 정렬된 배열로 합친다. 길이가 1인 부분 배열부터 병합하므로, 병합 단계의 각 부분 배열은 이미 정렬되어 있다. + +=== "<1>" + ![병합 정렬 단계](merge_sort.assets/merge_sort_step1.png){ class="animation-figure" } + +=== "<2>" + ![병합 정렬 2단계](merge_sort.assets/merge_sort_step2.png){ class="animation-figure" } + +=== "<3>" + ![병합 정렬 3단계](merge_sort.assets/merge_sort_step3.png){ class="animation-figure" } + +=== "<4>" + ![병합 정렬 4단계](merge_sort.assets/merge_sort_step4.png){ class="animation-figure" } + +=== "<5>" + ![병합 정렬 5단계](merge_sort.assets/merge_sort_step5.png){ class="animation-figure" } + +=== "<6>" + ![병합 정렬 6단계](merge_sort.assets/merge_sort_step6.png){ class="animation-figure" } + +=== "<7>" + ![병합 정렬 7단계](merge_sort.assets/merge_sort_step7.png){ class="animation-figure" } + +=== "<8>" + ![병합 정렬 8단계](merge_sort.assets/merge_sort_step8.png){ class="animation-figure" } + +=== "<9>" + ![병합 정렬 9단계](merge_sort.assets/merge_sort_step9.png){ class="animation-figure" } + +=== "<10>" + ![병합 정렬 10단계](merge_sort.assets/merge_sort_step10.png){ class="animation-figure" } + +

그림 11-11   병합 정렬 단계

+ +병합 정렬의 재귀 순서는 이진 트리의 후위 순회와 같다는 것을 알 수 있다. + +- **후위 순회**: 왼쪽 서브트리에 재귀한 다음 오른쪽 서브트리에 재귀하고, 마지막으로 루트 노드를 처리한다. +- **병합 정렬**: 왼쪽 부분 배열에 재귀한 다음 오른쪽 부분 배열에 재귀하고, 마지막으로 병합한다. + +병합 정렬의 구현은 다음 코드와 같다. `nums`에서 병합할 구간은 `[left, right]`이고, 이에 대응하는 `tmp`의 구간은 `[0, right - left]`라는 점에 주의하자. + +=== "Python" + + ```python title="merge_sort.py" + def merge(nums: list[int], left: int, mid: int, right: int): + """왼쪽 부분 배열과 오른쪽 부분 배열 병합""" + # 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + # 병합 결과를 저장할 임시 배열 tmp를 생성한다 + tmp = [0] * (right - left + 1) + # 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + i, j, k = left, mid + 1, 0 + # 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while i <= mid and j <= right: + if nums[i] <= nums[j]: + tmp[k] = nums[i] + i += 1 + else: + tmp[k] = nums[j] + j += 1 + k += 1 + # 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while i <= mid: + tmp[k] = nums[i] + i += 1 + k += 1 + while j <= right: + tmp[k] = nums[j] + j += 1 + k += 1 + # 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for k in range(0, len(tmp)): + nums[left + k] = tmp[k] + + def merge_sort(nums: list[int], left: int, right: int): + """병합 정렬""" + # 종료 조건 + if left >= right: + return # 부분 배열의 길이가 1이면 재귀를 종료한다 + # 분할 단계 + mid = (left + right) // 2 # 중간 위치를 계산한다 + merge_sort(nums, left, mid) # 왼쪽 부분 배열에 재귀 호출한다 + merge_sort(nums, mid + 1, right) # 오른쪽 부분 배열에 재귀 호출한다 + # 병합 단계 + merge(nums, left, mid, right) + ``` + +=== "C++" + + ```cpp title="merge_sort.cpp" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + void merge(vector &nums, int left, int mid, int right) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + vector tmp(right - left + 1); + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + int i = left, j = mid + 1, k = 0; + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while (i <= mid && j <= right) { + if (nums[i] <= nums[j]) + tmp[k++] = nums[i++]; + else + tmp[k++] = nums[j++]; + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while (i <= mid) { + tmp[k++] = nums[i++]; + } + while (j <= right) { + tmp[k++] = nums[j++]; + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for (k = 0; k < tmp.size(); k++) { + nums[left + k] = tmp[k]; + } + } + + /* 병합 정렬 */ + void mergeSort(vector &nums, int left, int right) { + // 종료 조건 + if (left >= right) + return; // 부분 배열의 길이가 1이면 재귀를 종료한다 + // 분할 단계 + int mid = left + (right - left) / 2; // 중간 위치를 계산한다 + mergeSort(nums, left, mid); // 왼쪽 부분 배열에 재귀 호출한다 + mergeSort(nums, mid + 1, right); // 오른쪽 부분 배열에 재귀 호출한다 + // 병합 단계 + merge(nums, left, mid, right); + } + ``` + +=== "Java" + + ```java title="merge_sort.java" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + void merge(int[] nums, int left, int mid, int right) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + int[] tmp = new int[right - left + 1]; + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + int i = left, j = mid + 1, k = 0; + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while (i <= mid && j <= right) { + if (nums[i] <= nums[j]) + tmp[k++] = nums[i++]; + else + tmp[k++] = nums[j++]; + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while (i <= mid) { + tmp[k++] = nums[i++]; + } + while (j <= right) { + tmp[k++] = nums[j++]; + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for (k = 0; k < tmp.length; k++) { + nums[left + k] = tmp[k]; + } + } + + /* 병합 정렬 */ + void mergeSort(int[] nums, int left, int right) { + // 종료 조건 + if (left >= right) + return; // 부분 배열의 길이가 1이면 재귀를 종료한다 + // 분할 단계 + int mid = left + (right - left) / 2; // 중간 위치를 계산한다 + mergeSort(nums, left, mid); // 왼쪽 부분 배열에 재귀 호출한다 + mergeSort(nums, mid + 1, right); // 오른쪽 부분 배열에 재귀 호출한다 + // 병합 단계 + merge(nums, left, mid, right); + } + ``` + +=== "C#" + + ```csharp title="merge_sort.cs" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + void Merge(int[] nums, int left, int mid, int right) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + int[] tmp = new int[right - left + 1]; + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + int i = left, j = mid + 1, k = 0; + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while (i <= mid && j <= right) { + if (nums[i] <= nums[j]) + tmp[k++] = nums[i++]; + else + tmp[k++] = nums[j++]; + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while (i <= mid) { + tmp[k++] = nums[i++]; + } + while (j <= right) { + tmp[k++] = nums[j++]; + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for (k = 0; k < tmp.Length; ++k) { + nums[left + k] = tmp[k]; + } + } + + /* 병합 정렬 */ + void MergeSort(int[] nums, int left, int right) { + // 종료 조건 + if (left >= right) return; // 부분 배열의 길이가 1이면 재귀를 종료한다 + // 분할 단계 + int mid = left + (right - left) / 2; // 중간 위치를 계산한다 + MergeSort(nums, left, mid); // 왼쪽 부분 배열에 재귀 호출한다 + MergeSort(nums, mid + 1, right); // 오른쪽 부분 배열에 재귀 호출한다 + // 병합 단계 + Merge(nums, left, mid, right); + } + ``` + +=== "Go" + + ```go title="merge_sort.go" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + func merge(nums []int, left, mid, right int) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + tmp := make([]int, right-left+1) + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + i, j, k := left, mid+1, 0 + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + for i <= mid && j <= right { + if nums[i] <= nums[j] { + tmp[k] = nums[i] + i++ + } else { + tmp[k] = nums[j] + j++ + } + k++ + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + for i <= mid { + tmp[k] = nums[i] + i++ + k++ + } + for j <= right { + tmp[k] = nums[j] + j++ + k++ + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for k := 0; k < len(tmp); k++ { + nums[left+k] = tmp[k] + } + } + + /* 병합 정렬 */ + func mergeSort(nums []int, left, right int) { + // 종료 조건 + if left >= right { + return + } + // 분할 단계 + mid := left + (right - left) / 2 + mergeSort(nums, left, mid) + mergeSort(nums, mid+1, right) + // 병합 단계 + merge(nums, left, mid, right) + } + ``` + +=== "Swift" + + ```swift title="merge_sort.swift" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + func merge(nums: inout [Int], left: Int, mid: Int, right: Int) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + var tmp = Array(repeating: 0, count: right - left + 1) + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + var i = left, j = mid + 1, k = 0 + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while i <= mid, j <= right { + if nums[i] <= nums[j] { + tmp[k] = nums[i] + i += 1 + } else { + tmp[k] = nums[j] + j += 1 + } + k += 1 + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while i <= mid { + tmp[k] = nums[i] + i += 1 + k += 1 + } + while j <= right { + tmp[k] = nums[j] + j += 1 + k += 1 + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for k in tmp.indices { + nums[left + k] = tmp[k] + } + } + + /* 병합 정렬 */ + func mergeSort(nums: inout [Int], left: Int, right: Int) { + // 종료 조건 + if left >= right { // 부분 배열의 길이가 1이면 재귀를 종료한다 + return + } + // 분할 단계 + let mid = left + (right - left) / 2 // 중간 위치를 계산한다 + mergeSort(nums: &nums, left: left, right: mid) // 왼쪽 부분 배열에 재귀 호출한다 + mergeSort(nums: &nums, left: mid + 1, right: right) // 오른쪽 부분 배열에 재귀 호출한다 + // 병합 단계 + merge(nums: &nums, left: left, mid: mid, right: right) + } + ``` + +=== "JS" + + ```javascript title="merge_sort.js" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + function merge(nums, left, mid, right) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + const tmp = new Array(right - left + 1); + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + let i = left, + j = mid + 1, + k = 0; + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while (i <= mid && j <= right) { + if (nums[i] <= nums[j]) { + tmp[k++] = nums[i++]; + } else { + tmp[k++] = nums[j++]; + } + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while (i <= mid) { + tmp[k++] = nums[i++]; + } + while (j <= right) { + tmp[k++] = nums[j++]; + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for (k = 0; k < tmp.length; k++) { + nums[left + k] = tmp[k]; + } + } + + /* 병합 정렬 */ + function mergeSort(nums, left, right) { + // 종료 조건 + if (left >= right) return; // 부분 배열의 길이가 1이면 재귀를 종료한다 + // 분할 단계 + let mid = Math.floor(left + (right - left) / 2); // 중간 위치를 계산한다 + mergeSort(nums, left, mid); // 왼쪽 부분 배열에 재귀 호출한다 + mergeSort(nums, mid + 1, right); // 오른쪽 부분 배열에 재귀 호출한다 + // 병합 단계 + merge(nums, left, mid, right); + } + ``` + +=== "TS" + + ```typescript title="merge_sort.ts" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + function merge(nums: number[], left: number, mid: number, right: number): void { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + const tmp = new Array(right - left + 1); + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + let i = left, + j = mid + 1, + k = 0; + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while (i <= mid && j <= right) { + if (nums[i] <= nums[j]) { + tmp[k++] = nums[i++]; + } else { + tmp[k++] = nums[j++]; + } + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while (i <= mid) { + tmp[k++] = nums[i++]; + } + while (j <= right) { + tmp[k++] = nums[j++]; + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for (k = 0; k < tmp.length; k++) { + nums[left + k] = tmp[k]; + } + } + + /* 병합 정렬 */ + function mergeSort(nums: number[], left: number, right: number): void { + // 종료 조건 + if (left >= right) return; // 부분 배열의 길이가 1이면 재귀를 종료한다 + // 분할 단계 + let mid = Math.floor(left + (right - left) / 2); // 중간 위치를 계산한다 + mergeSort(nums, left, mid); // 왼쪽 부분 배열에 재귀 호출한다 + mergeSort(nums, mid + 1, right); // 오른쪽 부분 배열에 재귀 호출한다 + // 병합 단계 + merge(nums, left, mid, right); + } + ``` + +=== "Dart" + + ```dart title="merge_sort.dart" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + void merge(List nums, int left, int mid, int right) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + List tmp = List.filled(right - left + 1, 0); + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + int i = left, j = mid + 1, k = 0; + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while (i <= mid && j <= right) { + if (nums[i] <= nums[j]) + tmp[k++] = nums[i++]; + else + tmp[k++] = nums[j++]; + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while (i <= mid) { + tmp[k++] = nums[i++]; + } + while (j <= right) { + tmp[k++] = nums[j++]; + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for (k = 0; k < tmp.length; k++) { + nums[left + k] = tmp[k]; + } + } + + /* 병합 정렬 */ + void mergeSort(List nums, int left, int right) { + // 종료 조건 + if (left >= right) return; // 부분 배열의 길이가 1이면 재귀를 종료한다 + // 분할 단계 + int mid = left + (right - left) ~/ 2; // 중간 위치를 계산한다 + mergeSort(nums, left, mid); // 왼쪽 부분 배열에 재귀 호출한다 + mergeSort(nums, mid + 1, right); // 오른쪽 부분 배열에 재귀 호출한다 + // 병합 단계 + merge(nums, left, mid, right); + } + ``` + +=== "Rust" + + ```rust title="merge_sort.rs" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + fn merge(nums: &mut [i32], left: usize, mid: usize, right: usize) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + let tmp_size = right - left + 1; + let mut tmp = vec![0; tmp_size]; + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + let (mut i, mut j, mut k) = (left, mid + 1, 0); + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while i <= mid && j <= right { + if nums[i] <= nums[j] { + tmp[k] = nums[i]; + i += 1; + } else { + tmp[k] = nums[j]; + j += 1; + } + k += 1; + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while i <= mid { + tmp[k] = nums[i]; + k += 1; + i += 1; + } + while j <= right { + tmp[k] = nums[j]; + k += 1; + j += 1; + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for k in 0..tmp_size { + nums[left + k] = tmp[k]; + } + } + + /* 병합 정렬 */ + fn merge_sort(nums: &mut [i32], left: usize, right: usize) { + // 종료 조건 + if left >= right { + return; // 부분 배열의 길이가 1이면 재귀를 종료한다 + } + + // 분할 단계 + let mid = left + (right - left) / 2; // 중간 위치를 계산한다 + merge_sort(nums, left, mid); // 왼쪽 부분 배열에 재귀 호출한다 + merge_sort(nums, mid + 1, right); // 오른쪽 부분 배열에 재귀 호출한다 + + // 병합 단계 + merge(nums, left, mid, right); + } + ``` + +=== "C" + + ```c title="merge_sort.c" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + void merge(int *nums, int left, int mid, int right) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + int tmpSize = right - left + 1; + int *tmp = (int *)malloc(tmpSize * sizeof(int)); + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + int i = left, j = mid + 1, k = 0; + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while (i <= mid && j <= right) { + if (nums[i] <= nums[j]) { + tmp[k++] = nums[i++]; + } else { + tmp[k++] = nums[j++]; + } + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while (i <= mid) { + tmp[k++] = nums[i++]; + } + while (j <= right) { + tmp[k++] = nums[j++]; + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for (k = 0; k < tmpSize; ++k) { + nums[left + k] = tmp[k]; + } + // 메모리 해제 + free(tmp); + } + + /* 병합 정렬 */ + void mergeSort(int *nums, int left, int right) { + // 종료 조건 + if (left >= right) + return; // 부분 배열의 길이가 1이면 재귀를 종료한다 + // 분할 단계 + int mid = left + (right - left) / 2; // 중간 위치를 계산한다 + mergeSort(nums, left, mid); // 왼쪽 부분 배열에 재귀 호출한다 + mergeSort(nums, mid + 1, right); // 오른쪽 부분 배열에 재귀 호출한다 + // 병합 단계 + merge(nums, left, mid, right); + } + ``` + +=== "Kotlin" + + ```kotlin title="merge_sort.kt" + /* 왼쪽 부분 배열과 오른쪽 부분 배열 병합 */ + fun merge(nums: IntArray, left: Int, mid: Int, right: Int) { + // 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + // 병합 결과를 저장할 임시 배열 tmp를 생성한다 + val tmp = IntArray(right - left + 1) + // 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + var i = left + var j = mid + 1 + var k = 0 + // 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while (i <= mid && j <= right) { + if (nums[i] <= nums[j]) + tmp[k++] = nums[i++] + else + tmp[k++] = nums[j++] + } + // 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while (i <= mid) { + tmp[k++] = nums[i++] + } + while (j <= right) { + tmp[k++] = nums[j++] + } + // 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + for (l in tmp.indices) { + nums[left + l] = tmp[l] + } + } + + /* 병합 정렬 */ + fun mergeSort(nums: IntArray, left: Int, right: Int) { + // 종료 조건 + if (left >= right) return // 부분 배열의 길이가 1이면 재귀를 종료한다 + // 분할 단계 + val mid = left + (right - left) / 2 // 중간 위치를 계산한다 + mergeSort(nums, left, mid) // 왼쪽 부분 배열에 재귀 호출한다 + mergeSort(nums, mid + 1, right) // 오른쪽 부분 배열에 재귀 호출한다 + // 병합 단계 + merge(nums, left, mid, right) + } + ``` + +=== "Ruby" + + ```ruby title="merge_sort.rb" + ### 왼쪽 부분 배열과 오른쪽 부분 배열 병합 ### + def merge(nums, left, mid, right) + # 왼쪽 부분 배열의 구간은 [left, mid]이고, 오른쪽 부분 배열의 구간은 [mid+1, right]이다 + # 병합 결과를 저장할 임시 배열 tmp를 생성한다 + tmp = Array.new(right - left + 1, 0) + # 왼쪽 부분 배열과 오른쪽 부분 배열의 시작 인덱스를 초기화한다 + i, j, k = left, mid + 1, 0 + # 양쪽 부분 배열 모두에 원소가 남아 있으면 비교하여 더 작은 원소를 임시 배열에 복사한다 + while i <= mid && j <= right + if nums[i] <= nums[j] + tmp[k] = nums[i] + i += 1 + else + tmp[k] = nums[j] + j += 1 + end + k += 1 + end + # 왼쪽 부분 배열과 오른쪽 부분 배열의 남은 원소를 임시 배열에 복사한다 + while i <= mid + tmp[k] = nums[i] + i += 1 + k += 1 + end + while j <= right + tmp[k] = nums[j] + j += 1 + k += 1 + end + # 임시 배열 tmp의 원소를 원래 배열 nums의 해당 구간에 다시 복사한다 + (0...tmp.length).each do |k| + nums[left + k] = tmp[k] + end + end + + ### 병합 정렬 ### + def merge_sort(nums, left, right) + # 종료 조건 + # 부분 배열의 길이가 1이면 재귀를 종료한다 + return if left >= right + # 분할 단계 + mid = left + (right - left) / 2 # 중간 위치를 계산한다 + merge_sort(nums, left, mid) # 왼쪽 부분 배열에 재귀 호출한다 + merge_sort(nums, mid + 1, right) # 오른쪽 부분 배열에 재귀 호출한다 + # 병합 단계 + merge(nums, left, mid, right) + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 11.6.2   알고리즘 특성 + +- **시간 복잡도 $O(n \log n)$, 비적응형 정렬**: 분할 과정에서 높이 $\log n$의 재귀 트리가 만들어지고, 각 레벨에서 병합에 필요한 총연산 수는 $n$이므로 전체 시간 복잡도는 $O(n \log n)$이다. +- **공간 복잡도 $O(n)$, 제자리 정렬이 아님**: 재귀 깊이가 $\log n$이므로 $O(\log n)$ 크기의 스택 프레임 공간을 사용한다. 병합에는 보조 배열이 필요하여 $O(n)$의 추가 공간을 사용한다. +- **안정 정렬**: 병합 과정에서 값이 같은 원소들의 순서가 유지된다. + +## 11.6.3   연결 리스트 정렬 + +연결 리스트에서는 병합 정렬이 다른 정렬 알고리즘보다 뚜렷한 장점을 가진다. **연결 리스트 정렬의 공간 복잡도를 $O(1)$까지 최적화할 수 있다**. + +- **분할 단계**: 연결 리스트를 나눌 때 '재귀' 대신 '반복'을 사용하면 재귀에 필요한 스택 프레임 공간을 없앨 수 있다. +- **병합 단계**: 연결 리스트에서는 참조(포인터)만 바꾸어 노드를 추가하거나 삭제할 수 있다. 따라서 정렬된 짧은 연결 리스트 두 개를 하나의 긴 연결 리스트로 합칠 때 추가 연결 리스트를 만들 필요가 없다. + +구체적인 구현은 다소 복잡하므로, 관심 있는 독자는 관련 자료를 찾아 학습해 보자. diff --git a/ko/docs/chapter_sorting/quick_sort.md b/ko/docs/chapter_sorting/quick_sort.md new file mode 100644 index 000000000..055b0aacc --- /dev/null +++ b/ko/docs/chapter_sorting/quick_sort.md @@ -0,0 +1,1377 @@ +--- +comments: true +--- + +# 11.5   퀵 정렬 + +퀵 정렬(quick sort)은 분할 정복 전략에 기반한 정렬 알고리즘으로, 실행 효율이 높고 널리 사용된다. + +퀵 정렬의 핵심 연산은 '센티널 분할'이다. 배열의 한 원소를 '피벗'으로 고른 다음, 피벗보다 작은 원소는 모두 왼쪽으로, 큰 원소는 모두 오른쪽으로 옮기는 것이 목표다. 구체적인 과정은 아래 그림(11-8)과 같다. + +1. 배열의 맨 왼쪽 원소를 피벗으로 고르고, 포인터 `i`와 `j`가 배열의 양 끝을 가리키도록 초기화한다. +2. 반복문을 실행하여 매번 `i`(`j`)로 피벗보다 큰(작은) 첫 번째 원소를 각각 찾은 뒤 두 원소를 교환한다. +3. `i`와 `j`가 만날 때까지 `2.` 단계를 반복한다. 마지막으로 피벗을 두 부분 배열의 경계로 옮긴다. + +=== "<1>" + ![센티널 분할 단계](quick_sort.assets/pivot_division_step1.png){ class="animation-figure" } + +=== "<2>" + ![센티널 분할 2단계](quick_sort.assets/pivot_division_step2.png){ class="animation-figure" } + +=== "<3>" + ![센티널 분할 3단계](quick_sort.assets/pivot_division_step3.png){ class="animation-figure" } + +=== "<4>" + ![센티널 분할 4단계](quick_sort.assets/pivot_division_step4.png){ class="animation-figure" } + +=== "<5>" + ![센티널 분할 5단계](quick_sort.assets/pivot_division_step5.png){ class="animation-figure" } + +=== "<6>" + ![센티널 분할 6단계](quick_sort.assets/pivot_division_step6.png){ class="animation-figure" } + +=== "<7>" + ![센티널 분할 7단계](quick_sort.assets/pivot_division_step7.png){ class="animation-figure" } + +=== "<8>" + ![센티널 분할 8단계](quick_sort.assets/pivot_division_step8.png){ class="animation-figure" } + +=== "<9>" + ![센티널 분할 9단계](quick_sort.assets/pivot_division_step9.png){ class="animation-figure" } + +

그림 11-8   센티널 분할 단계

+ +분할을 마치면 원래 배열은 왼쪽 부분 배열, 피벗, 오른쪽 부분 배열의 세 부분으로 나뉘며, '왼쪽 부분 배열의 모든 원소 $\leq$ 피벗 $\leq$ 오른쪽 부분 배열의 모든 원소'를 만족한다. 따라서 이후에는 두 부분 배열만 정렬하면 된다. + +!!! note "퀵 정렬의 분할 정복 전략" + + 센티널 분할은 본질적으로 긴 배열 하나를 정렬하는 문제를 짧은 배열 두 개를 정렬하는 문제로 단순화한다. + +=== "Python" + + ```python title="quick_sort.py" + def partition(self, nums: list[int], left: int, right: int) -> int: + """센티널 분할""" + # nums[left]를 피벗으로 삼는다 + i, j = left, right + while i < j: + while i < j and nums[j] >= nums[left]: + j -= 1 # 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while i < j and nums[i] <= nums[left]: + i += 1 # 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + # 원소 교환 + nums[i], nums[j] = nums[j], nums[i] + # 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + nums[i], nums[left] = nums[left], nums[i] + return i # 피벗의 인덱스를 반환한다 + ``` + +=== "C++" + + ```cpp title="quick_sort.cpp" + /* 센티널 분할 */ + int partition(vector &nums, int left, int right) { + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) + j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) + i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + swap(nums[i], nums[j]); // 두 원소를 교환한다 + } + swap(nums[i], nums[left]); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Java" + + ```java title="quick_sort.java" + /* 원소 교환 */ + void swap(int[] nums, int i, int j) { + int tmp = nums[i]; + nums[i] = nums[j]; + nums[j] = tmp; + } + + /* 센티널 분할 */ + int partition(int[] nums, int left, int right) { + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) + j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) + i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + swap(nums, i, j); // 두 원소를 교환한다 + } + swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "C#" + + ```csharp title="quick_sort.cs" + /* 원소 교환 */ + void Swap(int[] nums, int i, int j) { + (nums[j], nums[i]) = (nums[i], nums[j]); + } + + /* 센티널 분할 */ + int Partition(int[] nums, int left, int right) { + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) + j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) + i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + Swap(nums, i, j); // 두 원소를 교환한다 + } + Swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Go" + + ```go title="quick_sort.go" + /* 센티널 분할 */ + func (q *quickSort) partition(nums []int, left, right int) int { + // nums[left]를 피벗으로 삼는다 + i, j := left, right + for i < j { + for i < j && nums[j] >= nums[left] { + j-- // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + } + for i < j && nums[i] <= nums[left] { + i++ // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + } + // 원소 교환 + nums[i], nums[j] = nums[j], nums[i] + } + // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + nums[i], nums[left] = nums[left], nums[i] + return i // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Swift" + + ```swift title="quick_sort.swift" + /* 센티널 분할 */ + func partition(nums: inout [Int], left: Int, right: Int) -> Int { + // nums[left]를 피벗으로 삼는다 + var i = left + var j = right + while i < j { + while i < j, nums[j] >= nums[left] { + j -= 1 // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + } + while i < j, nums[i] <= nums[left] { + i += 1 // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + } + nums.swapAt(i, j) // 두 원소를 교환한다 + } + nums.swapAt(i, left) // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i // 피벗의 인덱스를 반환한다 + } + ``` + +=== "JS" + + ```javascript title="quick_sort.js" + /* 원소 교환 */ + swap(nums, i, j) { + let tmp = nums[i]; + nums[i] = nums[j]; + nums[j] = tmp; + } + + /* 센티널 분할 */ + partition(nums, left, right) { + // nums[left]를 피벗으로 삼는다 + let i = left, + j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) { + j -= 1; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + } + while (i < j && nums[i] <= nums[left]) { + i += 1; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + } + // 원소 교환 + this.swap(nums, i, j); // 두 원소를 교환한다 + } + this.swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "TS" + + ```typescript title="quick_sort.ts" + /* 원소 교환 */ + swap(nums: number[], i: number, j: number): void { + let tmp = nums[i]; + nums[i] = nums[j]; + nums[j] = tmp; + } + + /* 센티널 분할 */ + partition(nums: number[], left: number, right: number): number { + // nums[left]를 피벗으로 삼는다 + let i = left, + j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) { + j -= 1; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + } + while (i < j && nums[i] <= nums[left]) { + i += 1; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + } + // 원소 교환 + this.swap(nums, i, j); // 두 원소를 교환한다 + } + this.swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Dart" + + ```dart title="quick_sort.dart" + /* 원소 교환 */ + void _swap(List nums, int i, int j) { + int tmp = nums[i]; + nums[i] = nums[j]; + nums[j] = tmp; + } + + /* 센티널 분할 */ + int _partition(List nums, int left, int right) { + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + _swap(nums, i, j); // 두 원소를 교환한다 + } + _swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Rust" + + ```rust title="quick_sort.rs" + /* 센티널 분할 */ + fn partition(nums: &mut [i32], left: usize, right: usize) -> usize { + // nums[left]를 피벗으로 삼는다 + let (mut i, mut j) = (left, right); + while i < j { + while i < j && nums[j] >= nums[left] { + j -= 1; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + } + while i < j && nums[i] <= nums[left] { + i += 1; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + } + nums.swap(i, j); // 두 원소를 교환한다 + } + nums.swap(i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + i // 피벗의 인덱스를 반환한다 + } + ``` + +=== "C" + + ```c title="quick_sort.c" + /* 원소 교환 */ + void swap(int nums[], int i, int j) { + int tmp = nums[i]; + nums[i] = nums[j]; + nums[j] = tmp; + } + + /* 센티널 분할 */ + int partition(int nums[], int left, int right) { + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) { + j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + } + while (i < j && nums[i] <= nums[left]) { + i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + } + // 두 원소를 교환한다 + swap(nums, i, j); + } + // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + swap(nums, i, left); + // 피벗의 인덱스를 반환한다 + return i; + } + ``` + +=== "Kotlin" + + ```kotlin title="quick_sort.kt" + /* 원소 교환 */ + fun swap(nums: IntArray, i: Int, j: Int) { + val temp = nums[i] + nums[i] = nums[j] + nums[j] = temp + } + + /* 센티널 분할 */ + fun partition(nums: IntArray, left: Int, right: Int): Int { + // nums[left]를 피벗으로 삼는다 + var i = left + var j = right + while (i < j) { + while (i < j && nums[j] >= nums[left]) + j-- // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) + i++ // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + swap(nums, i, j) // 두 원소를 교환한다 + } + swap(nums, i, left) // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Ruby" + + ```ruby title="quick_sort.rb" + ### 센티널 분할 ### + def partition(nums, left, right) + # nums[left]를 피벗으로 삼는다 + i, j = left, right + while i < j + while i < j && nums[j] >= nums[left] + j -= 1 # 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + end + while i < j && nums[i] <= nums[left] + i += 1 # 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + end + # 원소 교환 + nums[i], nums[j] = nums[j], nums[i] + end + # 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + nums[i], nums[left] = nums[left], nums[i] + i # 피벗의 인덱스를 반환한다 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 11.5.1   알고리즘 과정 + +퀵 정렬의 전체 과정은 아래 그림(11-9)과 같다. + +1. 먼저 원래 배열에 '센티널 분할'을 한 번 수행하여 정렬되지 않은 왼쪽 부분 배열과 오른쪽 부분 배열을 얻는다. +2. 이어서 왼쪽 부분 배열과 오른쪽 부분 배열에 각각 '센티널 분할'을 재귀적으로 수행한다. +3. 부분 배열의 길이가 1이 될 때까지 재귀를 계속하면 배열 전체의 정렬이 완료된다. + +![퀵 정렬 과정](quick_sort.assets/quick_sort_overview.png){ class="animation-figure" } + +

그림 11-9   퀵 정렬 과정

+ +=== "Python" + + ```python title="quick_sort.py" + def quick_sort(self, nums: list[int], left: int, right: int): + """퀵 정렬""" + # 부분 배열의 길이가 1이면 재귀를 종료한다 + if left >= right: + return + # 센티널 분할 + pivot = self.partition(nums, left, right) + # 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + self.quick_sort(nums, left, pivot - 1) + self.quick_sort(nums, pivot + 1, right) + ``` + +=== "C++" + + ```cpp title="quick_sort.cpp" + /* 퀵 정렬 */ + void quickSort(vector &nums, int left, int right) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if (left >= right) + return; + // 센티널 분할 + int pivot = partition(nums, left, right); + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + quickSort(nums, left, pivot - 1); + quickSort(nums, pivot + 1, right); + } + ``` + +=== "Java" + + ```java title="quick_sort.java" + /* 퀵 정렬 */ + void quickSort(int[] nums, int left, int right) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if (left >= right) + return; + // 센티널 분할 + int pivot = partition(nums, left, right); + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + quickSort(nums, left, pivot - 1); + quickSort(nums, pivot + 1, right); + } + ``` + +=== "C#" + + ```csharp title="quick_sort.cs" + /* 퀵 정렬 */ + void QuickSort(int[] nums, int left, int right) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if (left >= right) + return; + // 센티널 분할 + int pivot = Partition(nums, left, right); + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + QuickSort(nums, left, pivot - 1); + QuickSort(nums, pivot + 1, right); + } + ``` + +=== "Go" + + ```go title="quick_sort.go" + /* 퀵 정렬 */ + func (q *quickSort) quickSort(nums []int, left, right int) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if left >= right { + return + } + // 센티널 분할 + pivot := q.partition(nums, left, right) + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + q.quickSort(nums, left, pivot-1) + q.quickSort(nums, pivot+1, right) + } + ``` + +=== "Swift" + + ```swift title="quick_sort.swift" + /* 퀵 정렬 */ + func quickSort(nums: inout [Int], left: Int, right: Int) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if left >= right { + return + } + // 센티널 분할 + let pivot = partition(nums: &nums, left: left, right: right) + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + quickSort(nums: &nums, left: left, right: pivot - 1) + quickSort(nums: &nums, left: pivot + 1, right: right) + } + ``` + +=== "JS" + + ```javascript title="quick_sort.js" + /* 퀵 정렬 */ + quickSort(nums, left, right) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if (left >= right) return; + // 센티널 분할 + const pivot = this.partition(nums, left, right); + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + this.quickSort(nums, left, pivot - 1); + this.quickSort(nums, pivot + 1, right); + } + ``` + +=== "TS" + + ```typescript title="quick_sort.ts" + /* 퀵 정렬 */ + quickSort(nums: number[], left: number, right: number): void { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if (left >= right) { + return; + } + // 센티널 분할 + const pivot = this.partition(nums, left, right); + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + this.quickSort(nums, left, pivot - 1); + this.quickSort(nums, pivot + 1, right); + } + ``` + +=== "Dart" + + ```dart title="quick_sort.dart" + /* 퀵 정렬 */ + void quickSort(List nums, int left, int right) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if (left >= right) return; + // 센티널 분할 + int pivot = _partition(nums, left, right); + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + quickSort(nums, left, pivot - 1); + quickSort(nums, pivot + 1, right); + } + ``` + +=== "Rust" + + ```rust title="quick_sort.rs" + /* 퀵 정렬 */ + pub fn quick_sort(left: i32, right: i32, nums: &mut [i32]) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if left >= right { + return; + } + // 센티널 분할 + let pivot = Self::partition(nums, left as usize, right as usize) as i32; + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + Self::quick_sort(left, pivot - 1, nums); + Self::quick_sort(pivot + 1, right, nums); + } + ``` + +=== "C" + + ```c title="quick_sort.c" + /* 퀵 정렬 */ + void quickSort(int nums[], int left, int right) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if (left >= right) { + return; + } + // 센티널 분할 + int pivot = partition(nums, left, right); + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + quickSort(nums, left, pivot - 1); + quickSort(nums, pivot + 1, right); + } + ``` + +=== "Kotlin" + + ```kotlin title="quick_sort.kt" + /* 퀵 정렬 */ + fun quickSort(nums: IntArray, left: Int, right: Int) { + // 부분 배열의 길이가 1이면 재귀를 종료한다 + if (left >= right) return + // 센티널 분할 + val pivot = partition(nums, left, right) + // 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + quickSort(nums, left, pivot - 1) + quickSort(nums, pivot + 1, right) + } + ``` + +=== "Ruby" + + ```ruby title="quick_sort.rb" + ### 퀵 정렬 클래스 ### + def quick_sort(nums, left, right) + # 부분 배열의 길이가 1이 아니면 재귀 호출한다 + if left < right + # 센티널 분할 + pivot = partition(nums, left, right) + # 왼쪽 부분 배열과 오른쪽 부분 배열에 재귀 호출한다 + quick_sort(nums, left, pivot - 1) + quick_sort(nums, pivot + 1, right) + end + nums + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 11.5.2   알고리즘 특성 + +- **시간 복잡도 $O(n \log n)$, 비적응형 정렬**: 평균적으로 분할의 재귀 레벨 수는 $\log n$이고 각 레벨의 총반복 횟수는 $n$이므로 전체 시간은 $O(n \log n)$이다. 최악의 경우 매 분할에서 길이 $n$의 배열이 길이 $0$과 $n - 1$인 두 부분 배열로 나뉜다. 이때 재귀 레벨 수는 $n$에 이르고 각 레벨의 반복 횟수는 $n$이므로 전체 시간은 $O(n^2)$이다. +- **공간 복잡도 $O(n)$, 제자리 정렬**: 입력 배열이 완전히 역순이면 재귀 깊이가 최악의 값인 $n$에 이르므로 $O(n)$의 스택 프레임 공간을 사용한다. 정렬 자체는 추가 배열 없이 원래 배열에서 수행한다. +- **불안정 정렬**: 분할의 마지막 단계에서 피벗이 값이 같은 원소의 오른쪽으로 교환될 수 있다. + +## 11.5.3   퀵 정렬이 빠른 이유 + +이름에서도 알 수 있듯이 퀵 정렬은 효율 측면에서 장점이 있다. 평균 시간 복잡도는 '병합 정렬', '힙 정렬'과 같지만 보통 퀵 정렬이 더 효율적이다. 주요 이유는 다음과 같다. + +- **최악의 경우가 발생할 확률이 낮다**: 퀵 정렬의 최악 시간 복잡도는 $O(n^2)$으로 병합 정렬만큼 안정적이지 않지만, 대부분의 경우 $O(n \log n)$ 시간에 실행된다. +- **캐시 활용 효율이 높다**: 분할을 수행할 때 시스템이 부분 배열 전체를 캐시에 올릴 수 있어 원소 접근이 효율적이다. 반면 '힙 정렬' 같은 알고리즘은 원소를 건너뛰며 접근하므로 이런 특성이 없다. +- **복잡도의 상수 계수가 작다**: 위 세 알고리즘 중 퀵 정렬은 비교, 대입, 교환 등의 총연산 수가 가장 적다. '삽입 정렬'이 '버블 정렬'보다 빠른 이유와 비슷하다. + +## 11.5.4   피벗 선택 최적화 + +**일부 입력에서는 퀵 정렬의 시간 효율이 떨어질 수 있다**. 극단적인 예로 입력 배열이 완전히 역순이라고 하자. 맨 왼쪽 원소를 피벗으로 고르면 분할 후 피벗이 맨 오른쪽으로 이동하여 왼쪽 부분 배열의 길이는 $n - 1$, 오른쪽 부분 배열의 길이는 $0$이 된다. 이런 식으로 재귀하면 매번 한쪽 부분 배열의 길이가 $0$이 되어 분할 정복 전략이 제 기능을 하지 못하고, 퀵 정렬은 '버블 정렬'과 비슷한 형태로 퇴화한다. + +이 상황을 최대한 피하려면 **분할에 사용할 피벗의 선택 전략을 최적화할 수 있다**. 예를 들어 원소 하나를 무작위로 골라 피벗으로 삼을 수 있다. 하지만 운이 나빠 매번 좋지 않은 피벗을 고르면 여전히 만족스러운 효율을 얻지 못한다. + +프로그래밍 언어가 일반적으로 생성하는 것은 '의사 난수'라는 점에 주의하자. 의사 난수 수열에 맞추어 특정 테스트 사례를 만들면 퀵 정렬의 효율이 여전히 저하될 수 있다. + +더 개선하려면 배열에서 후보 원소 세 개(보통 첫 원소, 마지막 원소, 중간 원소)를 고르고, **이 세 후보의 중앙값을 피벗으로 사용한다**. 그러면 피벗이 '너무 작지도 크지도 않을' 확률이 크게 높아진다. 후보를 더 많이 골라 알고리즘의 견고함을 높일 수도 있다. 이 방법을 사용하면 시간 복잡도가 $O(n^2)$으로 저하될 확률이 크게 줄어든다. + +예시 코드는 다음과 같다. + +=== "Python" + + ```python title="quick_sort.py" + def median_three(self, nums: list[int], left: int, mid: int, right: int) -> int: + """세 후보 원소의 중앙값 선택""" + l, m, r = nums[left], nums[mid], nums[right] + if (l <= m <= r) or (r <= m <= l): + return mid # m은 l과 r 사이에 있다 + if (m <= l <= r) or (r <= l <= m): + return left # l은 m과 r 사이에 있다 + return right + + def partition(self, nums: list[int], left: int, right: int) -> int: + """센티널 분할(세 수의 중앙값 선택)""" + # nums[left]를 피벗으로 삼는다 + med = self.median_three(nums, left, (left + right) // 2, right) + # 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + nums[left], nums[med] = nums[med], nums[left] + # nums[left]를 피벗으로 삼는다 + i, j = left, right + while i < j: + while i < j and nums[j] >= nums[left]: + j -= 1 # 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while i < j and nums[i] <= nums[left]: + i += 1 # 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + # 원소 교환 + nums[i], nums[j] = nums[j], nums[i] + # 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + nums[i], nums[left] = nums[left], nums[i] + return i # 피벗의 인덱스를 반환한다 + ``` + +=== "C++" + + ```cpp title="quick_sort.cpp" + /* 세 후보 원소의 중앙값 선택 */ + int medianThree(vector &nums, int left, int mid, int right) { + int l = nums[left], m = nums[mid], r = nums[right]; + if ((l <= m && m <= r) || (r <= m && m <= l)) + return mid; // m은 l과 r 사이에 있다 + if ((m <= l && l <= r) || (r <= l && l <= m)) + return left; // l은 m과 r 사이에 있다 + return right; + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + int partition(vector &nums, int left, int right) { + // 세 후보 원소의 중앙값 선택 + int med = medianThree(nums, left, (left + right) / 2, right); + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + swap(nums[left], nums[med]); + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) + j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) + i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + swap(nums[i], nums[j]); // 두 원소를 교환한다 + } + swap(nums[i], nums[left]); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Java" + + ```java title="quick_sort.java" + /* 세 후보 원소의 중앙값 선택 */ + int medianThree(int[] nums, int left, int mid, int right) { + int l = nums[left], m = nums[mid], r = nums[right]; + if ((l <= m && m <= r) || (r <= m && m <= l)) + return mid; // m은 l과 r 사이에 있다 + if ((m <= l && l <= r) || (r <= l && l <= m)) + return left; // l은 m과 r 사이에 있다 + return right; + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + int partition(int[] nums, int left, int right) { + // 세 후보 원소의 중앙값 선택 + int med = medianThree(nums, left, (left + right) / 2, right); + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + swap(nums, left, med); + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) + j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) + i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + swap(nums, i, j); // 두 원소를 교환한다 + } + swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "C#" + + ```csharp title="quick_sort.cs" + /* 세 후보 원소의 중앙값 선택 */ + int MedianThree(int[] nums, int left, int mid, int right) { + int l = nums[left], m = nums[mid], r = nums[right]; + if ((l <= m && m <= r) || (r <= m && m <= l)) + return mid; // m은 l과 r 사이에 있다 + if ((m <= l && l <= r) || (r <= l && l <= m)) + return left; // l은 m과 r 사이에 있다 + return right; + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + int Partition(int[] nums, int left, int right) { + // 세 후보 원소의 중앙값 선택 + int med = MedianThree(nums, left, (left + right) / 2, right); + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + Swap(nums, left, med); + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) + j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) + i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + Swap(nums, i, j); // 두 원소를 교환한다 + } + Swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Go" + + ```go title="quick_sort.go" + /* 세 후보 원소의 중앙값 선택 */ + func (q *quickSortMedian) medianThree(nums []int, left, mid, right int) int { + l, m, r := nums[left], nums[mid], nums[right] + if (l <= m && m <= r) || (r <= m && m <= l) { + return mid // m은 l과 r 사이에 있다 + } + if (m <= l && l <= r) || (r <= l && l <= m) { + return left // l은 m과 r 사이에 있다 + } + return right + } + + /* 센티널 분할(세 수의 중앙값 선택)*/ + func (q *quickSortMedian) partition(nums []int, left, right int) int { + // nums[left]를 피벗으로 삼는다 + med := q.medianThree(nums, left, (left+right)/2, right) + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + nums[left], nums[med] = nums[med], nums[left] + // nums[left]를 피벗으로 삼는다 + i, j := left, right + for i < j { + for i < j && nums[j] >= nums[left] { + j-- //오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + } + for i < j && nums[i] <= nums[left] { + i++ //왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + } + //원소 교환 + nums[i], nums[j] = nums[j], nums[i] + } + //피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + nums[i], nums[left] = nums[left], nums[i] + return i //피벗의 인덱스를 반환한다 + } + ``` + +=== "Swift" + + ```swift title="quick_sort.swift" + /* 세 후보 원소의 중앙값 선택 */ + func medianThree(nums: [Int], left: Int, mid: Int, right: Int) -> Int { + let l = nums[left] + let m = nums[mid] + let r = nums[right] + if (l <= m && m <= r) || (r <= m && m <= l) { + return mid // m은 l과 r 사이에 있다 + } + if (m <= l && l <= r) || (r <= l && l <= m) { + return left // l은 m과 r 사이에 있다 + } + return right + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + func partitionMedian(nums: inout [Int], left: Int, right: Int) -> Int { + // 세 후보 원소의 중앙값 선택 + let med = medianThree(nums: nums, left: left, mid: left + (right - left) / 2, right: right) + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + nums.swapAt(left, med) + return partition(nums: &nums, left: left, right: right) + } + ``` + +=== "JS" + + ```javascript title="quick_sort.js" + /* 세 후보 원소의 중앙값 선택 */ + medianThree(nums, left, mid, right) { + let l = nums[left], + m = nums[mid], + r = nums[right]; + // m은 l과 r 사이에 있다 + if ((l <= m && m <= r) || (r <= m && m <= l)) return mid; + // l은 m과 r 사이에 있다 + if ((m <= l && l <= r) || (r <= l && l <= m)) return left; + return right; + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + partition(nums, left, right) { + // 세 후보 원소의 중앙값 선택 + let med = this.medianThree( + nums, + left, + Math.floor((left + right) / 2), + right + ); + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + this.swap(nums, left, med); + // nums[left]를 피벗으로 삼는다 + let i = left, + j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + this.swap(nums, i, j); // 두 원소를 교환한다 + } + this.swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "TS" + + ```typescript title="quick_sort.ts" + /* 세 후보 원소의 중앙값 선택 */ + medianThree( + nums: number[], + left: number, + mid: number, + right: number + ): number { + let l = nums[left], + m = nums[mid], + r = nums[right]; + // m은 l과 r 사이에 있다 + if ((l <= m && m <= r) || (r <= m && m <= l)) return mid; + // l은 m과 r 사이에 있다 + if ((m <= l && l <= r) || (r <= l && l <= m)) return left; + return right; + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + partition(nums: number[], left: number, right: number): number { + // 세 후보 원소의 중앙값 선택 + let med = this.medianThree( + nums, + left, + Math.floor((left + right) / 2), + right + ); + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + this.swap(nums, left, med); + // nums[left]를 피벗으로 삼는다 + let i = left, + j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) { + j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + } + while (i < j && nums[i] <= nums[left]) { + i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + } + this.swap(nums, i, j); // 두 원소를 교환한다 + } + this.swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Dart" + + ```dart title="quick_sort.dart" + /* 세 후보 원소의 중앙값 선택 */ + int _medianThree(List nums, int left, int mid, int right) { + int l = nums[left], m = nums[mid], r = nums[right]; + if ((l <= m && m <= r) || (r <= m && m <= l)) + return mid; // m은 l과 r 사이에 있다 + if ((m <= l && l <= r) || (r <= l && l <= m)) + return left; // l은 m과 r 사이에 있다 + return right; + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + int _partition(List nums, int left, int right) { + // 세 후보 원소의 중앙값 선택 + int med = _medianThree(nums, left, (left + right) ~/ 2, right); + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + _swap(nums, left, med); + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + _swap(nums, i, j); // 두 원소를 교환한다 + } + _swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Rust" + + ```rust title="quick_sort.rs" + /* 세 후보 원소의 중앙값 선택 */ + fn median_three(nums: &mut [i32], left: usize, mid: usize, right: usize) -> usize { + let (l, m, r) = (nums[left], nums[mid], nums[right]); + if (l <= m && m <= r) || (r <= m && m <= l) { + return mid; // m은 l과 r 사이에 있다 + } + if (m <= l && l <= r) || (r <= l && l <= m) { + return left; // l은 m과 r 사이에 있다 + } + right + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + fn partition(nums: &mut [i32], left: usize, right: usize) -> usize { + // 세 후보 원소의 중앙값 선택 + let med = Self::median_three(nums, left, (left + right) / 2, right); + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + nums.swap(left, med); + // nums[left]를 피벗으로 삼는다 + let (mut i, mut j) = (left, right); + while i < j { + while i < j && nums[j] >= nums[left] { + j -= 1; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + } + while i < j && nums[i] <= nums[left] { + i += 1; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + } + nums.swap(i, j); // 두 원소를 교환한다 + } + nums.swap(i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + i // 피벗의 인덱스를 반환한다 + } + ``` + +=== "C" + + ```c title="quick_sort.c" + /* 세 후보 원소의 중앙값 선택 */ + int medianThree(int nums[], int left, int mid, int right) { + int l = nums[left], m = nums[mid], r = nums[right]; + if ((l <= m && m <= r) || (r <= m && m <= l)) + return mid; // m은 l과 r 사이에 있다 + if ((m <= l && l <= r) || (r <= l && l <= m)) + return left; // l은 m과 r 사이에 있다 + return right; + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + int partitionMedian(int nums[], int left, int right) { + // 세 후보 원소의 중앙값 선택 + int med = medianThree(nums, left, (left + right) / 2, right); + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + swap(nums, left, med); + // nums[left]를 피벗으로 삼는다 + int i = left, j = right; + while (i < j) { + while (i < j && nums[j] >= nums[left]) + j--; // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) + i++; // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + swap(nums, i, j); // 두 원소를 교환한다 + } + swap(nums, i, left); // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i; // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Kotlin" + + ```kotlin title="quick_sort.kt" + /* 세 후보 원소의 중앙값 선택 */ + fun medianThree(nums: IntArray, left: Int, mid: Int, right: Int): Int { + val l = nums[left] + val m = nums[mid] + val r = nums[right] + if ((m in l..r) || (m in r..l)) + return mid // m은 l과 r 사이에 있다 + if ((l in m..r) || (l in r..m)) + return left // l은 m과 r 사이에 있다 + return right + } + + /* 센티널 분할(세 수의 중앙값 선택) */ + fun partitionMedian(nums: IntArray, left: Int, right: Int): Int { + // 세 후보 원소의 중앙값 선택 + val med = medianThree(nums, left, (left + right) / 2, right) + // 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + swap(nums, left, med) + // nums[left]를 피벗으로 삼는다 + var i = left + var j = right + while (i < j) { + while (i < j && nums[j] >= nums[left]) + j-- // 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + while (i < j && nums[i] <= nums[left]) + i++ // 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + swap(nums, i, j) // 두 원소를 교환한다 + } + swap(nums, i, left) // 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + return i // 피벗의 인덱스를 반환한다 + } + ``` + +=== "Ruby" + + ```ruby title="quick_sort.rb" + ### 세 후보 원소의 중앙값 선택 ### + def median_three(nums, left, mid, right) + # 세 후보 원소의 중앙값 선택 + _l, _m, _r = nums[left], nums[mid], nums[right] + # m은 l과 r 사이에 있다 + return mid if (_l <= _m && _m <= _r) || (_r <= _m && _m <= _l) + # l은 m과 r 사이에 있다 + return left if (_m <= _l && _l <= _r) || (_r <= _l && _l <= _m) + return right + end + + ### 센티널 분할(세 수의 중앙값 선택)### + def partition(nums, left, right) + ### nums[left]를 피벗으로 삼는다 + med = median_three(nums, left, (left + right) / 2, right) + # 중앙값을 배열 맨 왼쪽으로 교환하여 옮긴다 + nums[left], nums[med] = nums[med], nums[left] + i, j = left, right + while i < j + while i < j && nums[j] >= nums[left] + j -= 1 # 오른쪽에서 왼쪽으로 피벗보다 작은 첫 번째 원소를 찾는다 + end + while i < j && nums[i] <= nums[left] + i += 1 # 왼쪽에서 오른쪽으로 피벗보다 큰 첫 번째 원소를 찾는다 + end + # 원소 교환 + nums[i], nums[j] = nums[j], nums[i] + end + # 피벗을 두 부분 배열의 경계로 교환하여 옮긴다 + nums[i], nums[left] = nums[left], nums[i] + i # 피벗의 인덱스를 반환한다 + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 11.5.5   재귀 깊이 최적화 + +**일부 입력에서는 퀵 정렬이 많은 공간을 차지할 수 있다**. 완전히 정렬된 입력 배열을 예로 들어 보자. 재귀 중인 부분 배열의 길이가 $m$이라면 매 분할에서 길이 $0$인 왼쪽 부분 배열과 길이 $m - 1$인 오른쪽 부분 배열이 생긴다. 이는 재귀 한 단계마다 줄어드는 문제 크기가 매우 작다는 뜻이다(원소 하나만 줄어든다). 재귀 트리의 높이는 $n - 1$에 이르며, $O(n)$ 크기의 스택 프레임 공간이 필요하다. + +스택 프레임 공간이 쌓이지 않도록 매번 분할을 마친 뒤 두 부분 배열의 길이를 비교하여 **더 짧은 부분 배열에만 재귀를 수행할 수 있다**. 짧은 쪽의 길이는 $n / 2$를 넘지 않으므로 재귀 깊이를 $\log n$ 이하로 유지할 수 있다. 이로써 최악의 공간 복잡도를 $O(\log n)$으로 개선한다. 코드는 다음과 같다. + +=== "Python" + + ```python title="quick_sort.py" + def quick_sort(self, nums: list[int], left: int, right: int): + """퀵 정렬(재귀 깊이 최적화)""" + # 부분 배열의 길이가 1이면 종료한다 + while left < right: + # 센티널 분할 연산 + pivot = self.partition(nums, left, right) + # 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if pivot - left < right - pivot: + self.quick_sort(nums, left, pivot - 1) # 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1 # 남은 미정렬 구간은 [pivot + 1, right]이다 + else: + self.quick_sort(nums, pivot + 1, right) # 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1 # 남은 미정렬 구간은 [left, pivot - 1]이다 + ``` + +=== "C++" + + ```cpp title="quick_sort.cpp" + /* 퀵 정렬(재귀 깊이 최적화) */ + void quickSort(vector &nums, int left, int right) { + // 부분 배열의 길이가 1이면 종료한다 + while (left < right) { + // 센티널 분할 연산 + int pivot = partition(nums, left, right); + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if (pivot - left < right - pivot) { + quickSort(nums, left, pivot - 1); // 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1; // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + quickSort(nums, pivot + 1, right); // 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1; // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "Java" + + ```java title="quick_sort.java" + /* 퀵 정렬(재귀 깊이 최적화) */ + void quickSort(int[] nums, int left, int right) { + // 부분 배열의 길이가 1이면 종료한다 + while (left < right) { + // 센티널 분할 연산 + int pivot = partition(nums, left, right); + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if (pivot - left < right - pivot) { + quickSort(nums, left, pivot - 1); // 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1; // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + quickSort(nums, pivot + 1, right); // 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1; // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "C#" + + ```csharp title="quick_sort.cs" + /* 퀵 정렬(재귀 깊이 최적화) */ + void QuickSort(int[] nums, int left, int right) { + // 부분 배열의 길이가 1이면 종료한다 + while (left < right) { + // 센티널 분할 연산 + int pivot = Partition(nums, left, right); + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if (pivot - left < right - pivot) { + QuickSort(nums, left, pivot - 1); // 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1; // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + QuickSort(nums, pivot + 1, right); // 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1; // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "Go" + + ```go title="quick_sort.go" + /* 퀵 정렬(재귀 깊이 최적화)*/ + func (q *quickSortTailCall) quickSort(nums []int, left, right int) { + // 부분 배열의 길이가 1이면 종료한다 + for left < right { + // 센티널 분할 연산 + pivot := q.partition(nums, left, right) + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if pivot-left < right-pivot { + q.quickSort(nums, left, pivot-1) // 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1 // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + q.quickSort(nums, pivot+1, right) // 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1 // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "Swift" + + ```swift title="quick_sort.swift" + /* 퀵 정렬(재귀 깊이 최적화) */ + func quickSortTailCall(nums: inout [Int], left: Int, right: Int) { + var left = left + var right = right + // 부분 배열의 길이가 1이면 종료한다 + while left < right { + // 센티널 분할 연산 + let pivot = partition(nums: &nums, left: left, right: right) + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if (pivot - left) < (right - pivot) { + quickSortTailCall(nums: &nums, left: left, right: pivot - 1) // 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1 // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + quickSortTailCall(nums: &nums, left: pivot + 1, right: right) // 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1 // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "JS" + + ```javascript title="quick_sort.js" + /* 퀵 정렬(재귀 깊이 최적화) */ + quickSort(nums, left, right) { + // 부분 배열의 길이가 1이면 종료한다 + while (left < right) { + // 센티널 분할 연산 + let pivot = this.partition(nums, left, right); + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if (pivot - left < right - pivot) { + this.quickSort(nums, left, pivot - 1); // 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1; // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + this.quickSort(nums, pivot + 1, right); // 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1; // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "TS" + + ```typescript title="quick_sort.ts" + /* 퀵 정렬(재귀 깊이 최적화) */ + quickSort(nums: number[], left: number, right: number): void { + // 부분 배열의 길이가 1이면 종료한다 + while (left < right) { + // 센티널 분할 연산 + let pivot = this.partition(nums, left, right); + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if (pivot - left < right - pivot) { + this.quickSort(nums, left, pivot - 1); // 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1; // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + this.quickSort(nums, pivot + 1, right); // 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1; // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "Dart" + + ```dart title="quick_sort.dart" + /* 퀵 정렬(재귀 깊이 최적화) */ + void quickSort(List nums, int left, int right) { + // 부분 배열의 길이가 1이면 종료한다 + while (left < right) { + // 센티널 분할 연산 + int pivot = _partition(nums, left, right); + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if (pivot - left < right - pivot) { + quickSort(nums, left, pivot - 1); // 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1; // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + quickSort(nums, pivot + 1, right); // 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1; // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "Rust" + + ```rust title="quick_sort.rs" + /* 퀵 정렬(재귀 깊이 최적화) */ + pub fn quick_sort(mut left: i32, mut right: i32, nums: &mut [i32]) { + // 부분 배열의 길이가 1이면 종료한다 + while left < right { + // 센티널 분할 연산 + let pivot = Self::partition(nums, left as usize, right as usize) as i32; + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if pivot - left < right - pivot { + Self::quick_sort(left, pivot - 1, nums); // 왼쪽 부분 배열을 재귀적으로 정렬한다 + left = pivot + 1; // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + Self::quick_sort(pivot + 1, right, nums); // 오른쪽 부분 배열을 재귀적으로 정렬한다 + right = pivot - 1; // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "C" + + ```c title="quick_sort.c" + /* 퀵 정렬(재귀 깊이 최적화) */ + void quickSortTailCall(int nums[], int left, int right) { + // 부분 배열의 길이가 1이면 종료한다 + while (left < right) { + // 센티널 분할 연산 + int pivot = partition(nums, left, right); + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if (pivot - left < right - pivot) { + // 왼쪽 부분 배열을 재귀적으로 정렬한다 + quickSortTailCall(nums, left, pivot - 1); + // 남은 미정렬 구간은 [pivot + 1, right]이다 + left = pivot + 1; + } else { + // 오른쪽 부분 배열을 재귀적으로 정렬한다 + quickSortTailCall(nums, pivot + 1, right); + // 남은 미정렬 구간은 [left, pivot - 1]이다 + right = pivot - 1; + } + } + } + ``` + +=== "Kotlin" + + ```kotlin title="quick_sort.kt" + /* 퀵 정렬(재귀 깊이 최적화) */ + fun quickSortTailCall(nums: IntArray, left: Int, right: Int) { + // 부분 배열의 길이가 1이면 종료한다 + var l = left + var r = right + while (l < r) { + // 센티널 분할 연산 + val pivot = partition(nums, l, r) + // 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if (pivot - l < r - pivot) { + quickSort(nums, l, pivot - 1) // 왼쪽 부분 배열을 재귀적으로 정렬한다 + l = pivot + 1 // 남은 미정렬 구간은 [pivot + 1, right]이다 + } else { + quickSort(nums, pivot + 1, r) // 오른쪽 부분 배열을 재귀적으로 정렬한다 + r = pivot - 1 // 남은 미정렬 구간은 [left, pivot - 1]이다 + } + } + } + ``` + +=== "Ruby" + + ```ruby title="quick_sort.rb" + ### 퀵 정렬(재귀 깊이 최적화)### + def quick_sort(nums, left, right) + # 부분 배열의 길이가 1이 아니면 재귀 호출한다 + while left < right + # 센티널 분할 + pivot = partition(nums, left, right) + # 두 부분 배열 중 더 짧은 쪽에 퀵 정렬을 수행한다 + if pivot - left < right - pivot + quick_sort(nums, left, pivot - 1) + left = pivot + 1 # 남은 미정렬 구간은 [pivot + 1, right]이다 + else + quick_sort(nums, pivot + 1, right) + right = pivot - 1 # 남은 미정렬 구간은 [left, pivot - 1]이다 + end + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ diff --git a/ko/docs/chapter_sorting/radix_sort.md b/ko/docs/chapter_sorting/radix_sort.md new file mode 100644 index 000000000..31cb046a3 --- /dev/null +++ b/ko/docs/chapter_sorting/radix_sort.md @@ -0,0 +1,734 @@ +--- +comments: true +--- + +# 11.10   기수 정렬 + +앞 절에서 배운 계수 정렬은 데이터 수 $n$은 많고 값의 범위 $m$은 작은 경우에 적합하다. 학번 $n = 10^6$ 개를 정렬해야 하며, 학번이 $8$ 자리 수라고 하자. 이때 값의 범위는 $m = 10^8$로 매우 커서 계수 정렬에는 많은 메모리가 필요하다. 기수 정렬을 사용하면 이 문제를 피할 수 있다. + +기수 정렬(radix sort)의 핵심 아이디어는 계수 정렬과 같으며, 개수를 세어 정렬한다. 여기에 자릿수 사이의 순서 관계를 이용하여 각 자릿수를 차례로 정렬함으로써 최종 정렬 결과를 얻는다. + +## 11.10.1   알고리즘 과정 + +학번을 예로 들어 가장 낮은 자리를 $1$ 번째, 가장 높은 자리를 $8$ 번째라고 하자. 기수 정렬의 과정은 아래 그림(11-18)과 같다. + +1. 자릿수 $k = 1$로 초기화한다. +2. 학번의 $k$ 번째 자리를 기준으로 '계수 정렬'을 수행한다. 완료하면 데이터는 $k$ 번째 자리의 값에 따라 오름차순으로 정렬된다. +3. $k$를 $1$ 늘리고 `2.` 단계로 돌아가 반복한다. 모든 자리를 정렬하면 종료한다. + +![기수 정렬 알고리즘 과정](radix_sort.assets/radix_sort_overview.png){ class="animation-figure" } + +

그림 11-18   기수 정렬 알고리즘 과정

+ +이제 코드 구현을 살펴보자. $d$ 진법으로 표현한 수 $x$의 $k$ 번째 자리 $x_k$는 다음 공식으로 구할 수 있다. + +$$ +x_k = \lfloor\frac{x}{d^{k-1}}\rfloor \bmod d +$$ + +여기서 $\lfloor a \rfloor$는 부동 소수점 수 $a$를 내림하는 연산이고, $\bmod \: d$는 $d$로 나눈 나머지를 구하는 연산이다. 학번 데이터에서는 $d = 10$이고 $k \in [1, 8]$이다. + +또한 수의 $k$ 번째 자리를 기준으로 정렬할 수 있도록 계수 정렬 코드를 조금 수정해야 한다. + +=== "Python" + + ```python title="radix_sort.py" + def digit(num: int, exp: int) -> int: + """원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다""" + # k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return (num // exp) % 10 + + def counting_sort_digit(nums: list[int], exp: int): + """계수 정렬(nums의 k번째 자릿값을 기준으로 정렬)""" + # 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + counter = [0] * 10 + n = len(nums) + # 0~9 각 숫자의 등장 횟수를 센다 + for i in range(n): + d = digit(nums[i], exp) # nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d] += 1 # 숫자 d의 등장 횟수를 센다 + # 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for i in range(1, 10): + counter[i] += counter[i - 1] + # 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + res = [0] * n + for i in range(n - 1, -1, -1): + d = digit(nums[i], exp) + j = counter[d] - 1 # 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i] # 현재 원소를 인덱스 j에 채운다 + counter[d] -= 1 # d의 개수를 1 줄인다 + # 결과로 원래 배열 nums를 덮어쓴다 + for i in range(n): + nums[i] = res[i] + + def radix_sort(nums: list[int]): + """기수 정렬""" + # 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + m = max(nums) + # 낮은 자리에서 높은 자리 순서로 순회한다 + exp = 1 + while exp <= m: + # 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + # k = 1 -> exp = 1 + # k = 2 -> exp = 10 + # 즉, exp = 10^(k-1)이다 + counting_sort_digit(nums, exp) + exp *= 10 + ``` + +=== "C++" + + ```cpp title="radix_sort.cpp" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + int digit(int num, int exp) { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return (num / exp) % 10; + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + void countingSortDigit(vector &nums, int exp) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + vector counter(10, 0); + int n = nums.size(); + // 0~9 각 숫자의 등장 횟수를 센다 + for (int i = 0; i < n; i++) { + int d = digit(nums[i], exp); // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d]++; // 숫자 d의 등장 횟수를 센다 + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for (int i = 1; i < 10; i++) { + counter[i] += counter[i - 1]; + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + vector res(n, 0); + for (int i = n - 1; i >= 0; i--) { + int d = digit(nums[i], exp); + int j = counter[d] - 1; // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i]; // 현재 원소를 인덱스 j에 채운다 + counter[d]--; // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + for (int i = 0; i < n; i++) + nums[i] = res[i]; + } + + /* 기수 정렬 */ + void radixSort(vector &nums) { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + int m = *max_element(nums.begin(), nums.end()); + // 낮은 자리에서 높은 자리 순서로 순회한다 + for (int exp = 1; exp <= m; exp *= 10) + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + countingSortDigit(nums, exp); + } + ``` + +=== "Java" + + ```java title="radix_sort.java" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + int digit(int num, int exp) { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return (num / exp) % 10; + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + void countingSortDigit(int[] nums, int exp) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + int[] counter = new int[10]; + int n = nums.length; + // 0~9 각 숫자의 등장 횟수를 센다 + for (int i = 0; i < n; i++) { + int d = digit(nums[i], exp); // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d]++; // 숫자 d의 등장 횟수를 센다 + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for (int i = 1; i < 10; i++) { + counter[i] += counter[i - 1]; + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + int[] res = new int[n]; + for (int i = n - 1; i >= 0; i--) { + int d = digit(nums[i], exp); + int j = counter[d] - 1; // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i]; // 현재 원소를 인덱스 j에 채운다 + counter[d]--; // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + for (int i = 0; i < n; i++) + nums[i] = res[i]; + } + + /* 기수 정렬 */ + void radixSort(int[] nums) { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + int m = Integer.MIN_VALUE; + for (int num : nums) + if (num > m) + m = num; + // 낮은 자리에서 높은 자리 순서로 순회한다 + for (int exp = 1; exp <= m; exp *= 10) { + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + countingSortDigit(nums, exp); + } + } + ``` + +=== "C#" + + ```csharp title="radix_sort.cs" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + int Digit(int num, int exp) { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return (num / exp) % 10; + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + void CountingSortDigit(int[] nums, int exp) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + int[] counter = new int[10]; + int n = nums.Length; + // 0~9 각 숫자의 등장 횟수를 센다 + for (int i = 0; i < n; i++) { + int d = Digit(nums[i], exp); // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d]++; // 숫자 d의 등장 횟수를 센다 + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for (int i = 1; i < 10; i++) { + counter[i] += counter[i - 1]; + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + int[] res = new int[n]; + for (int i = n - 1; i >= 0; i--) { + int d = Digit(nums[i], exp); + int j = counter[d] - 1; // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i]; // 현재 원소를 인덱스 j에 채운다 + counter[d]--; // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + for (int i = 0; i < n; i++) { + nums[i] = res[i]; + } + } + + /* 기수 정렬 */ + void RadixSort(int[] nums) { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + int m = int.MinValue; + foreach (int num in nums) { + if (num > m) m = num; + } + // 낮은 자리에서 높은 자리 순서로 순회한다 + for (int exp = 1; exp <= m; exp *= 10) { + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + CountingSortDigit(nums, exp); + } + } + ``` + +=== "Go" + + ```go title="radix_sort.go" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + func digit(num, exp int) int { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return (num / exp) % 10 + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + func countingSortDigit(nums []int, exp int) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + counter := make([]int, 10) + n := len(nums) + // 0~9 각 숫자의 등장 횟수를 센다 + for i := 0; i < n; i++ { + d := digit(nums[i], exp) // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d]++ // 숫자 d의 등장 횟수를 센다 + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for i := 1; i < 10; i++ { + counter[i] += counter[i-1] + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + res := make([]int, n) + for i := n - 1; i >= 0; i-- { + d := digit(nums[i], exp) + j := counter[d] - 1 // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i] // 현재 원소를 인덱스 j에 채운다 + counter[d]-- // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + for i := 0; i < n; i++ { + nums[i] = res[i] + } + } + + /* 기수 정렬 */ + func radixSort(nums []int) { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + max := math.MinInt + for _, num := range nums { + if num > max { + max = num + } + } + // 낮은 자리에서 높은 자리 순서로 순회한다 + for exp := 1; max >= exp; exp *= 10 { + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + countingSortDigit(nums, exp) + } + } + ``` + +=== "Swift" + + ```swift title="radix_sort.swift" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + func digit(num: Int, exp: Int) -> Int { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + (num / exp) % 10 + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + func countingSortDigit(nums: inout [Int], exp: Int) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + var counter = Array(repeating: 0, count: 10) + // 0~9 각 숫자의 등장 횟수를 센다 + for i in nums.indices { + let d = digit(num: nums[i], exp: exp) // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d] += 1 // 숫자 d의 등장 횟수를 센다 + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for i in 1 ..< 10 { + counter[i] += counter[i - 1] + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + var res = Array(repeating: 0, count: nums.count) + for i in nums.indices.reversed() { + let d = digit(num: nums[i], exp: exp) + let j = counter[d] - 1 // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i] // 현재 원소를 인덱스 j에 채운다 + counter[d] -= 1 // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + for i in nums.indices { + nums[i] = res[i] + } + } + + /* 기수 정렬 */ + func radixSort(nums: inout [Int]) { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + var m = Int.min + for num in nums { + if num > m { + m = num + } + } + // 낮은 자리에서 높은 자리 순서로 순회한다 + for exp in sequence(first: 1, next: { m >= ($0 * 10) ? $0 * 10 : nil }) { + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + countingSortDigit(nums: &nums, exp: exp) + } + } + ``` + +=== "JS" + + ```javascript title="radix_sort.js" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + function digit(num, exp) { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return Math.floor(num / exp) % 10; + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + function countingSortDigit(nums, exp) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + const counter = new Array(10).fill(0); + const n = nums.length; + // 0~9 각 숫자의 등장 횟수를 센다 + for (let i = 0; i < n; i++) { + const d = digit(nums[i], exp); // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d]++; // 숫자 d의 등장 횟수를 센다 + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for (let i = 1; i < 10; i++) { + counter[i] += counter[i - 1]; + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + const res = new Array(n).fill(0); + for (let i = n - 1; i >= 0; i--) { + const d = digit(nums[i], exp); + const j = counter[d] - 1; // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i]; // 현재 원소를 인덱스 j에 채운다 + counter[d]--; // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + for (let i = 0; i < n; i++) { + nums[i] = res[i]; + } + } + + /* 기수 정렬 */ + function radixSort(nums) { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + let m = Math.max(... nums); + // 낮은 자리에서 높은 자리 순서로 순회한다 + for (let exp = 1; exp <= m; exp *= 10) { + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + countingSortDigit(nums, exp); + } + } + ``` + +=== "TS" + + ```typescript title="radix_sort.ts" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + function digit(num: number, exp: number): number { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return Math.floor(num / exp) % 10; + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + function countingSortDigit(nums: number[], exp: number): void { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + const counter = new Array(10).fill(0); + const n = nums.length; + // 0~9 각 숫자의 등장 횟수를 센다 + for (let i = 0; i < n; i++) { + const d = digit(nums[i], exp); // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d]++; // 숫자 d의 등장 횟수를 센다 + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for (let i = 1; i < 10; i++) { + counter[i] += counter[i - 1]; + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + const res = new Array(n).fill(0); + for (let i = n - 1; i >= 0; i--) { + const d = digit(nums[i], exp); + const j = counter[d] - 1; // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i]; // 현재 원소를 인덱스 j에 채운다 + counter[d]--; // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + for (let i = 0; i < n; i++) { + nums[i] = res[i]; + } + } + + /* 기수 정렬 */ + function radixSort(nums: number[]): void { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + let m: number = Math.max(... nums); + // 낮은 자리에서 높은 자리 순서로 순회한다 + for (let exp = 1; exp <= m; exp *= 10) { + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + countingSortDigit(nums, exp); + } + } + ``` + +=== "Dart" + + ```dart title="radix_sort.dart" + /* 원소 _num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + int digit(int _num, int exp) { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return (_num ~/ exp) % 10; + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + void countingSortDigit(List nums, int exp) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + List counter = List.filled(10, 0); + int n = nums.length; + // 0~9 각 숫자의 등장 횟수를 센다 + for (int i = 0; i < n; i++) { + int d = digit(nums[i], exp); // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d]++; // 숫자 d의 등장 횟수를 센다 + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for (int i = 1; i < 10; i++) { + counter[i] += counter[i - 1]; + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + List res = List.filled(n, 0); + for (int i = n - 1; i >= 0; i--) { + int d = digit(nums[i], exp); + int j = counter[d] - 1; // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i]; // 현재 원소를 인덱스 j에 채운다 + counter[d]--; // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + for (int i = 0; i < n; i++) nums[i] = res[i]; + } + + /* 기수 정렬 */ + void radixSort(List nums) { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + // Dart에서 int의 길이는 64비트이다 + int m = -1 << 63; + for (int _num in nums) if (_num > m) m = _num; + // 낮은 자리에서 높은 자리 순서로 순회한다 + for (int exp = 1; exp <= m; exp *= 10) + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + countingSortDigit(nums, exp); + } + ``` + +=== "Rust" + + ```rust title="radix_sort.rs" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + fn digit(num: i32, exp: i32) -> usize { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return ((num / exp) % 10) as usize; + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + fn counting_sort_digit(nums: &mut [i32], exp: i32) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + let mut counter = [0; 10]; + let n = nums.len(); + // 0~9 각 숫자의 등장 횟수를 센다 + for i in 0..n { + let d = digit(nums[i], exp); // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d] += 1; // 숫자 d의 등장 횟수를 센다 + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for i in 1..10 { + counter[i] += counter[i - 1]; + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + let mut res = vec![0; n]; + for i in (0..n).rev() { + let d = digit(nums[i], exp); + let j = counter[d] - 1; // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i]; // 현재 원소를 인덱스 j에 채운다 + counter[d] -= 1; // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + nums.copy_from_slice(&res); + } + + /* 기수 정렬 */ + fn radix_sort(nums: &mut [i32]) { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + let m = *nums.into_iter().max().unwrap(); + // 낮은 자리에서 높은 자리 순서로 순회한다 + let mut exp = 1; + while exp <= m { + counting_sort_digit(nums, exp); + exp *= 10; + } + } + ``` + +=== "C" + + ```c title="radix_sort.c" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + int digit(int num, int exp) { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return (num / exp) % 10; + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + void countingSortDigit(int nums[], int size, int exp) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + int *counter = (int *)malloc((sizeof(int) * 10)); + memset(counter, 0, sizeof(int) * 10); // 이후 메모리 해제를 지원하도록 0으로 초기화한다 + // 0~9 각 숫자의 등장 횟수를 센다 + for (int i = 0; i < size; i++) { + // nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + int d = digit(nums[i], exp); + // 숫자 d의 등장 횟수를 센다 + counter[d]++; + } + // 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + for (int i = 1; i < 10; i++) { + counter[i] += counter[i - 1]; + } + // 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + int *res = (int *)malloc(sizeof(int) * size); + for (int i = size - 1; i >= 0; i--) { + int d = digit(nums[i], exp); + int j = counter[d] - 1; // 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i]; // 현재 원소를 인덱스 j에 채운다 + counter[d]--; // d의 개수를 1 줄인다 + } + // 결과로 원래 배열 nums를 덮어쓴다 + for (int i = 0; i < size; i++) { + nums[i] = res[i]; + } + // 메모리 해제 + free(res); + free(counter); + } + + /* 기수 정렬 */ + void radixSort(int nums[], int size) { + // 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + int max = INT32_MIN; + for (int i = 0; i < size; i++) { + if (nums[i] > max) { + max = nums[i]; + } + } + // 낮은 자리에서 높은 자리 순서로 순회한다 + for (int exp = 1; max >= exp; exp *= 10) + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + countingSortDigit(nums, size, exp); + } + ``` + +=== "Kotlin" + + ```kotlin title="radix_sort.kt" + /* 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 */ + fun digit(num: Int, exp: Int): Int { + // k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + return (num / exp) % 10 + } + + /* 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬) */ + fun countingSortDigit(nums: IntArray, exp: Int) { + // 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + val counter = IntArray(10) + val n = nums.size + // 0~9 각 숫자의 등장 횟수를 센다 + for (i in 0.. m) m = num + var exp = 1 + // 낮은 자리에서 높은 자리 순서로 순회한다 + while (exp <= m) { + // 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + // k = 1 -> exp = 1 + // k = 2 -> exp = 10 + // 즉, exp = 10^(k-1)이다 + countingSortDigit(nums, exp) + exp *= 10 + } + } + ``` + +=== "Ruby" + + ```ruby title="radix_sort.rb" + ### 원소 num의 k번째 자릿값 반환. 이때 exp = 10^(k-1)이다 ### + def digit(num, exp) + # k 대신 exp를 전달하면 여기서 비용이 큰 거듭제곱 계산을 반복하지 않아도 된다 + (num / exp) % 10 + end + + ### 계수 정렬(nums의 k번째 자릿값을 기준으로 정렬)### + def counting_sort_digit(nums, exp) + # 십진수의 각 자리는 0~9 범위이므로 길이가 10인 버킷 배열이 필요하다 + counter = Array.new(10, 0) + n = nums.length + # 0~9 각 숫자의 등장 횟수를 센다 + for i in 0...n + d = digit(nums[i], exp) # nums[i]의 k번째 자릿값을 구하여 d로 표시한다 + counter[d] += 1 # 숫자 d의 등장 횟수를 센다 + end + # 접두 합을 구하여 등장 개수를 배열 인덱스로 변환한다 + (1...10).each { |i| counter[i] += counter[i - 1] } + # 역순으로 순회하며 버킷에 집계된 결과에 따라 각 원소를 res에 채운다 + res = Array.new(n, 0) + for i in (n - 1).downto(0) + d = digit(nums[i], exp) + j = counter[d] - 1 # 배열에서 d의 인덱스 j를 구한다 + res[j] = nums[i] # 현재 원소를 인덱스 j에 채운다 + counter[d] -= 1 # d의 개수를 1 줄인다 + end + # 결과로 원래 배열 nums를 덮어쓴다 + (0...n).each { |i| nums[i] = res[i] } + end + + ### 기수 정렬 ### + def radix_sort(nums) + # 배열의 최댓값을 구하여 최대 자릿수를 판단한다 + m = nums.max + # 낮은 자리에서 높은 자리 순서로 순회한다 + exp = 1 + while exp <= m + # 배열 원소의 k번째 자릿값에 대해 계수 정렬을 수행한다 + # k = 1 -> exp = 1 + # k = 2 -> exp = 10 + # 즉, exp = 10^(k-1)이다 + counting_sort_digit(nums, exp) + exp *= 10 + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +!!! question "왜 가장 낮은 자리부터 정렬하는가?" + + 여러 차례 연속으로 정렬하면 뒤의 정렬 결과가 앞의 결과를 덮어쓴다. 예를 들어 첫 번째 정렬에서는 $a < b$였지만 두 번째 정렬에서는 $a > b$라면, 두 번째 결과가 첫 번째 결과를 대신한다. 높은 자릿수가 낮은 자릿수보다 우선하므로 낮은 자리부터 정렬한 뒤 높은 자리를 정렬해야 한다. + +## 11.10.2   알고리즘 특성 + +기수 정렬은 계수 정렬보다 값의 범위가 큰 경우에 적합하다. **단, 데이터를 고정된 자릿수의 형식으로 표현할 수 있어야 하며 자릿수가 지나치게 크면 안 된다**. 예를 들어 부동 소수점 수는 자릿수 $k$가 너무 커서 시간 복잡도가 $O(nk) \gg O(n^2)$이 될 수 있으므로 기수 정렬에 적합하지 않다. + +- **시간 복잡도 $O(nk)$, 비적응형 정렬**: 데이터 수가 $n$, 진법이 $d$, 최대 자릿수가 $k$라면 한 자리를 계수 정렬하는 데 $O(n + d)$ 시간이 걸리고, $k$ 자리 전체를 정렬하는 데 $O((n + d)k)$ 시간이 걸린다. 일반적으로 $d$와 $k$는 모두 비교적 작으므로 시간 복잡도는 $O(n)$에 가까워진다. +- **공간 복잡도 $O(n + d)$, 제자리 정렬이 아님**: 계수 정렬과 마찬가지로 길이가 $n$과 $d$인 배열 `res`와 `counter`가 필요하다. +- **안정 정렬**: 계수 정렬이 안정적이면 기수 정렬도 안정적이다. 계수 정렬이 불안정하면 기수 정렬은 올바른 정렬 결과를 보장할 수 없다. diff --git a/ko/docs/chapter_sorting/selection_sort.md b/ko/docs/chapter_sorting/selection_sort.md new file mode 100644 index 000000000..85da03c6c --- /dev/null +++ b/ko/docs/chapter_sorting/selection_sort.md @@ -0,0 +1,340 @@ +--- +comments: true +--- + +# 11.2   선택 정렬 + +선택 정렬(selection sort)의 원리는 매우 간단하다. 반복문을 실행하여 매번 정렬되지 않은 구간에서 가장 작은 원소를 고른 뒤 정렬된 구간의 끝에 놓는다. + +배열의 길이가 $n$일 때 선택 정렬의 과정은 아래 그림(11-2)과 같다. + +1. 처음에는 모든 원소가 정렬되지 않았으므로 미정렬 구간(인덱스)은 $[0, n-1]$이다. +2. 구간 $[0, n-1]$에서 가장 작은 원소를 골라 인덱스 $0$의 원소와 교환한다. 그러면 배열의 앞 1개 원소가 정렬된다. +3. 구간 $[1, n-1]$에서 가장 작은 원소를 골라 인덱스 $1$의 원소와 교환한다. 그러면 배열의 앞 2개 원소가 정렬된다. +4. 같은 과정을 반복한다. $n - 1$ 번 선택하고 교환하면 배열의 앞 $n - 1$ 개 원소가 정렬된다. +5. 남은 원소 하나는 반드시 가장 큰 원소이므로 정렬할 필요가 없으며, 배열 정렬이 완료된다. + +=== "<1>" + ![선택 정렬 단계](selection_sort.assets/selection_sort_step1.png){ class="animation-figure" } + +=== "<2>" + ![선택 정렬 2단계](selection_sort.assets/selection_sort_step2.png){ class="animation-figure" } + +=== "<3>" + ![선택 정렬 3단계](selection_sort.assets/selection_sort_step3.png){ class="animation-figure" } + +=== "<4>" + ![선택 정렬 4단계](selection_sort.assets/selection_sort_step4.png){ class="animation-figure" } + +=== "<5>" + ![선택 정렬 5단계](selection_sort.assets/selection_sort_step5.png){ class="animation-figure" } + +=== "<6>" + ![선택 정렬 6단계](selection_sort.assets/selection_sort_step6.png){ class="animation-figure" } + +=== "<7>" + ![선택 정렬 7단계](selection_sort.assets/selection_sort_step7.png){ class="animation-figure" } + +=== "<8>" + ![선택 정렬 8단계](selection_sort.assets/selection_sort_step8.png){ class="animation-figure" } + +=== "<9>" + ![선택 정렬 9단계](selection_sort.assets/selection_sort_step9.png){ class="animation-figure" } + +=== "<10>" + ![선택 정렬 10단계](selection_sort.assets/selection_sort_step10.png){ class="animation-figure" } + +=== "<11>" + ![선택 정렬 11단계](selection_sort.assets/selection_sort_step11.png){ class="animation-figure" } + +

그림 11-2   선택 정렬 단계

+ +코드에서는 $k$로 미정렬 구간의 최소 원소를 기록한다. + +=== "Python" + + ```python title="selection_sort.py" + def selection_sort(nums: list[int]): + """선택 정렬""" + n = len(nums) + # 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for i in range(n - 1): + # 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + k = i + for j in range(i + 1, n): + if nums[j] < nums[k]: + k = j # 최솟값의 인덱스를 기록한다 + # 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + nums[i], nums[k] = nums[k], nums[i] + ``` + +=== "C++" + + ```cpp title="selection_sort.cpp" + /* 선택 정렬 */ + void selectionSort(vector &nums) { + int n = nums.size(); + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for (int i = 0; i < n - 1; i++) { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + int k = i; + for (int j = i + 1; j < n; j++) { + if (nums[j] < nums[k]) + k = j; // 최솟값의 인덱스를 기록한다 + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + swap(nums[i], nums[k]); + } + } + ``` + +=== "Java" + + ```java title="selection_sort.java" + /* 선택 정렬 */ + void selectionSort(int[] nums) { + int n = nums.length; + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for (int i = 0; i < n - 1; i++) { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + int k = i; + for (int j = i + 1; j < n; j++) { + if (nums[j] < nums[k]) + k = j; // 최솟값의 인덱스를 기록한다 + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + int temp = nums[i]; + nums[i] = nums[k]; + nums[k] = temp; + } + } + ``` + +=== "C#" + + ```csharp title="selection_sort.cs" + /* 선택 정렬 */ + void SelectionSort(int[] nums) { + int n = nums.Length; + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for (int i = 0; i < n - 1; i++) { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + int k = i; + for (int j = i + 1; j < n; j++) { + if (nums[j] < nums[k]) + k = j; // 최솟값의 인덱스를 기록한다 + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + (nums[k], nums[i]) = (nums[i], nums[k]); + } + } + ``` + +=== "Go" + + ```go title="selection_sort.go" + /* 선택 정렬 */ + func selectionSort(nums []int) { + n := len(nums) + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for i := 0; i < n-1; i++ { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + k := i + for j := i + 1; j < n; j++ { + if nums[j] < nums[k] { + // 최솟값의 인덱스를 기록한다 + k = j + } + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + nums[i], nums[k] = nums[k], nums[i] + + } + } + ``` + +=== "Swift" + + ```swift title="selection_sort.swift" + /* 선택 정렬 */ + func selectionSort(nums: inout [Int]) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for i in nums.indices.dropLast() { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + var k = i + for j in nums.indices.dropFirst(i + 1) { + if nums[j] < nums[k] { + k = j // 최솟값의 인덱스를 기록한다 + } + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + nums.swapAt(i, k) + } + } + ``` + +=== "JS" + + ```javascript title="selection_sort.js" + /* 선택 정렬 */ + function selectionSort(nums) { + let n = nums.length; + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for (let i = 0; i < n - 1; i++) { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + let k = i; + for (let j = i + 1; j < n; j++) { + if (nums[j] < nums[k]) { + k = j; // 최솟값의 인덱스를 기록한다 + } + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + [nums[i], nums[k]] = [nums[k], nums[i]]; + } + } + ``` + +=== "TS" + + ```typescript title="selection_sort.ts" + /* 선택 정렬 */ + function selectionSort(nums: number[]): void { + let n = nums.length; + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for (let i = 0; i < n - 1; i++) { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + let k = i; + for (let j = i + 1; j < n; j++) { + if (nums[j] < nums[k]) { + k = j; // 최솟값의 인덱스를 기록한다 + } + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + [nums[i], nums[k]] = [nums[k], nums[i]]; + } + } + ``` + +=== "Dart" + + ```dart title="selection_sort.dart" + /* 선택 정렬 */ + void selectionSort(List nums) { + int n = nums.length; + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for (int i = 0; i < n - 1; i++) { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + int k = i; + for (int j = i + 1; j < n; j++) { + if (nums[j] < nums[k]) k = j; // 최솟값의 인덱스를 기록한다 + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + int temp = nums[i]; + nums[i] = nums[k]; + nums[k] = temp; + } + } + ``` + +=== "Rust" + + ```rust title="selection_sort.rs" + /* 선택 정렬 */ + fn selection_sort(nums: &mut [i32]) { + if nums.is_empty() { + return; + } + let n = nums.len(); + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for i in 0..n - 1 { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + let mut k = i; + for j in i + 1..n { + if nums[j] < nums[k] { + k = j; // 최솟값의 인덱스를 기록한다 + } + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + nums.swap(i, k); + } + } + ``` + +=== "C" + + ```c title="selection_sort.c" + /* 선택 정렬 */ + void selectionSort(int nums[], int n) { + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for (int i = 0; i < n - 1; i++) { + // 안쪽 반복문: 정렬되지 않은 구간의 최솟값을 찾는다 + int k = i; + for (int j = i + 1; j < n; j++) { + if (nums[j] < nums[k]) + k = j; // 최솟값의 인덱스를 기록한다 + } + // 이 최솟값과 정렬되지 않은 구간의 첫 원소를 교환한다 + int temp = nums[i]; + nums[i] = nums[k]; + nums[k] = temp; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="selection_sort.kt" + /* 선택 정렬 */ + fun selectionSort(nums: IntArray) { + val n = nums.size + // 바깥쪽 반복문: 정렬되지 않은 구간은 [i, n-1]이다 + for (i in 0.. + + +## 11.2.1   알고리즘 특성 + +- **시간 복잡도 $O(n^2)$, 비적응형 정렬**: 바깥 반복문은 $n - 1$ 번 실행된다. 안쪽 반복문은 첫 회에 $n - 1$ 번, 마지막 회에 $1$ 번 실행된다. 즉, 각 회의 실행 횟수는 $n - 1$, $n - 2$, $\dots$, $2$, $1$이고 합은 $\frac{n(n - 1)}{2}$이다. +- **공간 복잡도 $O(1)$, 제자리 정렬**: 포인터 $i$와 $j$는 상수 크기의 추가 공간을 사용한다. +- **불안정 정렬**: 아래 그림(11-3)과 같이 원소 `nums[i]`가 자신과 값이 같은 원소의 오른쪽으로 교환되어 두 원소의 상대적 순서가 바뀔 수 있다. + +![선택 정렬의 불안정성 예시](selection_sort.assets/selection_sort_instability.png){ class="animation-figure" } + +

그림 11-3   선택 정렬의 불안정성 예시

diff --git a/ko/docs/chapter_sorting/sorting_algorithm.md b/ko/docs/chapter_sorting/sorting_algorithm.md new file mode 100644 index 000000000..2c6e2c595 --- /dev/null +++ b/ko/docs/chapter_sorting/sorting_algorithm.md @@ -0,0 +1,52 @@ +--- +comments: true +--- + +# 11.1   정렬 알고리즘 + +정렬 알고리즘(sorting algorithm)은 데이터 집합을 특정 순서로 나열하는 데 사용한다. 정렬된 데이터는 대체로 더 효율적으로 탐색, 분석, 처리할 수 있으므로 정렬 알고리즘은 널리 활용된다. + +아래 그림(11-1)과 같이 정렬할 데이터의 타입은 정수, 부동 소수점 수, 문자, 문자열 등 다양하다. 정렬 기준도 수의 크기, 문자의 ASCII 코드 순서, 사용자 정의 규칙 등 필요에 따라 정할 수 있다. + +![데이터 타입과 정렬 기준의 예](sorting_algorithm.assets/sorting_examples.png){ class="animation-figure" } + +

그림 11-1   데이터 타입과 정렬 기준의 예

+ +## 11.1.1   평가 기준 + +**실행 효율**: 정렬 알고리즘은 시간 복잡도가 가능한 한 낮고, 전체 연산 수도 적기를 기대한다(시간 복잡도의 상수항이 작아야 한다). 데이터가 많을 때는 실행 효율이 특히 중요하다. + +**제자리 여부**: 이름 그대로 제자리 정렬은 추가 보조 배열 없이 원래 배열을 직접 조작하여 정렬하므로 메모리를 절약한다. 보통 데이터 이동 연산이 적어 실행 속도도 더 빠르다. + +**안정성**: 안정 정렬은 정렬 후에도 값이 같은 원소들의 배열 내 상대적 순서가 바뀌지 않는다. + +안정 정렬은 여러 기준으로 순차 정렬할 때 필요한 조건이다. 학생 정보를 저장한 표의 첫 번째 열이 이름, 두 번째 열이 나이라고 하자. 이때 불안정 정렬을 사용하면 입력 데이터가 기존에 정렬되어 있던 순서가 깨질 수 있다. + +```shell +# 입력 데이터는 이름순으로 정렬되어 있다 +# (name, age) + ('A', 19) + ('B', 18) + ('C', 21) + ('D', 19) + ('E', 23) + +# 불안정 정렬 알고리즘으로 리스트를 나이순으로 정렬하면, +# 결과에서 ('D', 19)와 ('A', 19)의 상대적 위치가 바뀌어, +# 입력 데이터의 이름순 정렬 상태가 사라질 수 있다 + ('B', 18) + ('D', 19) + ('A', 19) + ('C', 21) + ('E', 23) +``` + +**적응성**: 적응형 정렬은 입력 데이터에 이미 존재하는 순서 정보를 이용해 계산량을 줄이고 시간 효율을 높일 수 있다. 적응형 정렬 알고리즘의 최선 시간 복잡도는 보통 평균 시간 복잡도보다 낮다. + +**비교 기반 여부**: 비교 기반 정렬은 비교 연산자($<$, $=$, $>$)로 원소의 상대적 순서를 판단하여 배열 전체를 정렬한다. 최악의 시간 복잡도 하한은 $\Omega(n \log n)$이다. 반면 비교를 사용하지 않는 정렬은 비교 연산자를 사용하지 않으며 시간 복잡도가 $O(n)$에 이를 수 있지만, 범용성은 상대적으로 낮다. + +## 11.1.2   이상적인 정렬 알고리즘 + +**빠른 실행, 제자리 정렬, 안정성, 적응성, 높은 범용성**을 모두 갖추면 이상적이다. 하지만 지금까지 이러한 특성을 모두 갖춘 정렬 알고리즘은 발견되지 않았다. 따라서 데이터의 구체적인 특성과 문제의 요구에 맞추어 정렬 알고리즘을 선택해야 한다. + +이제 다양한 정렬 알고리즘을 배우고, 위 평가 기준에 따라 각각의 장단점을 분석해 보자. diff --git a/ko/docs/chapter_sorting/summary.md b/ko/docs/chapter_sorting/summary.md new file mode 100644 index 000000000..a061a54f5 --- /dev/null +++ b/ko/docs/chapter_sorting/summary.md @@ -0,0 +1,53 @@ +--- +comments: true +--- + +# 11.11   요약 + +### 1.   핵심 정리 + +- 버블 정렬은 인접한 원소를 교환하여 정렬한다. 플래그를 추가해 조기에 반환하도록 하면 최선 시간 복잡도를 $O(n)$으로 최적화할 수 있다. +- 삽입 정렬은 매번 미정렬 구간의 원소를 정렬된 구간의 올바른 위치에 삽입한다. 시간 복잡도는 $O(n^2)$이지만 기본 연산 수가 비교적 적어, 데이터가 적은 정렬 작업에서 매우 많이 사용된다. +- 퀵 정렬은 센티널 분할 연산으로 정렬한다. 분할할 때마다 최악의 피벗을 고르면 시간 복잡도가 $O(n^2)$으로 저하될 수 있다. 중앙값이나 무작위 원소를 피벗으로 사용하면 이런 확률을 줄일 수 있다. 더 짧은 부분 구간에 먼저 재귀하면 재귀 깊이를 효과적으로 줄여 공간 복잡도를 $O(\log n)$으로 최적화할 수 있다. +- 병합 정렬은 분할과 병합의 두 단계로 이루어지며 분할 정복 전략을 전형적으로 보여 준다. 배열을 정렬할 때는 보조 배열이 필요하여 공간 복잡도가 $O(n)$이지만, 연결 리스트 정렬은 $O(1)$까지 최적화할 수 있다. +- 버킷 정렬은 데이터를 버킷에 분배하고, 각 버킷 안에서 정렬하고, 결과를 합치는 세 단계로 이루어진다. 역시 분할 정복 전략을 보여 주며 매우 큰 데이터에 적합하다. 핵심은 데이터를 고르게 분배하는 것이다. +- 계수 정렬은 버킷 정렬의 특수한 경우로, 데이터의 등장 횟수를 세어 정렬한다. 데이터 수는 많지만 값의 범위가 제한된 경우에 적합하며, 데이터를 양의 정수로 변환할 수 있어야 한다. +- 기수 정렬은 자릿수별로 정렬하여 전체 데이터를 정렬하며, 데이터를 고정된 자릿수의 수로 표현할 수 있어야 한다. +- 우리는 효율, 안정성, 제자리 동작, 적응성 등의 장점을 갖춘 정렬 알고리즘을 원한다. 하지만 다른 자료 구조와 알고리즘처럼 이 모든 조건을 동시에 만족하는 정렬 알고리즘은 없다. 실제 응용에서는 데이터의 특성에 따라 적합한 알고리즘을 선택해야 한다. +- 아래 그림(11-19)은 주요 정렬 알고리즘의 효율, 안정성, 제자리 여부, 적응성 등을 비교한다. + +![정렬 알고리즘 비교](summary.assets/sorting_algorithms_comparison.png){ class="animation-figure" } + +

그림 11-19   정렬 알고리즘 비교

+ +### 2.   Q & A + +**Q**: 정렬 알고리즘의 안정성은 어떤 상황에서 필요한가? + +실제로 객체의 특정 속성을 기준으로 정렬하는 경우가 있다. 예를 들어 학생에게 이름과 키라는 두 속성이 있고, 여러 기준으로 순차 정렬하고 싶다고 하자. 먼저 이름순으로 정렬하여 `(A, 180) (B, 185) (C, 170) (D, 170)`을 얻은 뒤 키순으로 정렬한다. 정렬 알고리즘이 불안정하면 `(D, 170) (C, 170) (A, 180) (B, 185)`가 나올 수 있다. + +학생 D와 C의 위치가 바뀌어 이름순 정렬이 깨졌음을 알 수 있으며, 이는 원하는 결과가 아니다. + +**Q**: 센티널 분할에서 '오른쪽에서 왼쪽으로 찾기'와 '왼쪽에서 오른쪽으로 찾기'의 순서를 바꿔도 되는가? + +안 된다. 맨 왼쪽 원소를 피벗으로 선택하면 반드시 '오른쪽에서 왼쪽으로 찾기'를 먼저 수행하고, 그다음 '왼쪽에서 오른쪽으로 찾기'를 수행해야 한다. 직관과 다르게 느껴질 수 있으니 이유를 분석해 보자. + +분할 함수 `partition()`의 마지막 단계는 `nums[left]`와 `nums[i]`를 교환하는 것이다. 교환 후에는 피벗 왼쪽의 모든 원소에 대해 '원소 `<=` 피벗'이 성립해야 하므로, **마지막 교환 전에 반드시 `nums[left] >= nums[i]`가 성립해야 한다**. 먼저 '왼쪽에서 오른쪽으로 찾기'를 수행했는데 피벗보다 큰 원소를 찾지 못한다면, **`i == j`일 때 반복문을 빠져나오며 이때 `nums[j] == nums[i] > nums[left]`일 수 있다**. 즉, 마지막 교환이 피벗보다 큰 원소를 맨 왼쪽으로 옮겨 분할을 실패하게 할 수 있다. + +예를 들어 배열이 `[0, 0, 0, 0, 1]`일 때 '왼쪽에서 오른쪽으로 찾기'를 먼저 하면, 분할 결과가 `[1, 0, 0, 0, 0]`으로 잘못 나온다. + +더 생각해 보면 `nums[right]`를 피벗으로 선택했을 때는 반대로 '왼쪽에서 오른쪽으로 찾기'를 먼저 해야 한다. + +**Q**: 퀵 정렬의 재귀 깊이를 최적화할 때 짧은 배열을 선택하면 왜 깊이가 $\log n$을 넘지 않는가? + +재귀 깊이는 현재 아직 반환되지 않은 재귀 메서드의 수다. 매 분할에서 원래 배열을 두 부분 배열로 나눈다. 재귀 깊이를 최적화하면 아래로 재귀하는 부분 배열의 길이는 최대 원래 길이의 절반이다. 최악의 경우 매번 정확히 절반이라고 해도 최종 재귀 깊이는 $\log n$이다. + +원래 퀵 정렬에서는 더 긴 배열에 계속 재귀할 수 있다. 최악의 경우 길이가 $n$, $n - 1$, $\dots$, $2$, $1$로 줄어 재귀 깊이가 $n$이 된다. 재귀 깊이 최적화는 이 상황을 방지한다. + +**Q**: 배열의 모든 원소가 같으면 퀵 정렬의 시간 복잡도는 $O(n^2)$인가? 이런 퇴화는 어떻게 처리하는가? + +그렇다. 이 경우 배열을 피벗보다 작은 부분, 같은 부분, 큰 부분의 세 부분으로 나누고 작은 부분과 큰 부분에만 재귀하는 방법을 고려할 수 있다. 이렇게 하면 모든 원소가 같은 입력 배열은 분할 한 번으로 정렬된다. + +**Q**: 버킷 정렬의 최악 시간 복잡도는 왜 $O(n^2)$인가? + +최악의 경우 모든 원소가 같은 버킷에 들어간다. 이 원소들을 $O(n^2)$ 알고리즘으로 정렬한다면 시간 복잡도는 $O(n^2)$이다. diff --git a/ko/docs/chapter_stack_and_queue/deque.md b/ko/docs/chapter_stack_and_queue/deque.md new file mode 100644 index 000000000..4299d66e1 --- /dev/null +++ b/ko/docs/chapter_stack_and_queue/deque.md @@ -0,0 +1,3649 @@ +--- +comments: true +--- + +# 5.3   덱 + +큐에서는 맨 앞 원소를 삭제하거나 맨 뒤에 원소를 추가하는 것만 가능하다. 아래 그림(5-7)의 덱(double-ended queue)은 앞뒤 양쪽에서 원소를 추가하고 삭제할 수 있어 더 유연하다. + +![덱의 연산](deque.assets/deque_operations.png){ class="animation-figure" } + +

그림 5-7   덱의 연산

+ +## 5.3.1   덱의 기본 연산 + +덱의 기본 연산은 아래 표(5-3)와 같다. 구체적인 메서드 이름은 사용하는 프로그래밍 언어에 따라 정해진다. + +

표 5-3   덱의 연산 효율

+ +
+ +| 메서드 | 설명 | 시간 복잡도 | +| -------------- | ---------------- | ---------- | +| `push_first()` | 맨 앞에 원소 추가 | $O(1)$ | +| `push_last()` | 맨 뒤에 원소 추가 | $O(1)$ | +| `pop_first()` | 맨 앞 원소 삭제 | $O(1)$ | +| `pop_last()` | 맨 뒤 원소 삭제 | $O(1)$ | +| `peek_first()` | 맨 앞 원소에 접근 | $O(1)$ | +| `peek_last()` | 맨 뒤 원소에 접근 | $O(1)$ | + +
+ +마찬가지로 프로그래밍 언어가 제공하는 덱 클래스를 바로 사용할 수 있다. + +=== "Python" + + ```python title="deque.py" + from collections import deque + + # 덱 초기화 + deq: deque[int] = deque() + + # 덱에 원소 삽입 + deq.append(2) # 맨 뒤에 추가 + deq.append(5) + deq.append(4) + deq.appendleft(3) # 맨 앞에 추가 + deq.appendleft(1) + + # 원소 접근 + front: int = deq[0] # 맨 앞 원소 + rear: int = deq[-1] # 맨 뒤 원소 + + # 덱에서 원소 꺼내기 + pop_front: int = deq.popleft() # 맨 앞 원소 꺼내기 + pop_rear: int = deq.pop() # 맨 뒤 원소 꺼내기 + + # 덱 길이 구하기 + size: int = len(deq) + + # 덱이 비어 있는지 확인 + is_empty: bool = len(deq) == 0 + ``` + +=== "C++" + + ```cpp title="deque.cpp" + /* 덱 초기화 */ + deque deque; + + /* 덱에 원소 삽입 */ + deque.push_back(2); // 맨 뒤에 추가 + deque.push_back(5); + deque.push_back(4); + deque.push_front(3); // 맨 앞에 추가 + deque.push_front(1); + + /* 원소 접근 */ + int front = deque.front(); // 맨 앞 원소 + int back = deque.back(); // 맨 뒤 원소 + + /* 덱에서 원소 꺼내기 */ + deque.pop_front(); // 맨 앞 원소 꺼내기 + deque.pop_back(); // 맨 뒤 원소 꺼내기 + + /* 덱 길이 구하기 */ + int size = deque.size(); + + /* 덱이 비어 있는지 확인 */ + bool empty = deque.empty(); + ``` + +=== "Java" + + ```java title="deque.java" + /* 덱 초기화 */ + Deque deque = new LinkedList<>(); + + /* 덱에 원소 삽입 */ + deque.offerLast(2); // 맨 뒤에 추가 + deque.offerLast(5); + deque.offerLast(4); + deque.offerFirst(3); // 맨 앞에 추가 + deque.offerFirst(1); + + /* 원소 접근 */ + int peekFirst = deque.peekFirst(); // 맨 앞 원소 + int peekLast = deque.peekLast(); // 맨 뒤 원소 + + /* 덱에서 원소 꺼내기 */ + int popFirst = deque.pollFirst(); // 맨 앞 원소 꺼내기 + int popLast = deque.pollLast(); // 맨 뒤 원소 꺼내기 + + /* 덱 길이 구하기 */ + int size = deque.size(); + + /* 덱이 비어 있는지 확인 */ + boolean isEmpty = deque.isEmpty(); + ``` + +=== "C#" + + ```csharp title="deque.cs" + /* 덱 초기화 */ + // C#에서는 연결 리스트 LinkedList를 덱으로 사용한다 + LinkedList deque = new(); + + /* 덱에 원소 삽입 */ + deque.AddLast(2); // 맨 뒤에 추가 + deque.AddLast(5); + deque.AddLast(4); + deque.AddFirst(3); // 맨 앞에 추가 + deque.AddFirst(1); + + /* 원소 접근 */ + int peekFirst = deque.First.Value; // 맨 앞 원소 + int peekLast = deque.Last.Value; // 맨 뒤 원소 + + /* 덱에서 원소 꺼내기 */ + deque.RemoveFirst(); // 맨 앞 원소 꺼내기 + deque.RemoveLast(); // 맨 뒤 원소 꺼내기 + + /* 덱 길이 구하기 */ + int size = deque.Count; + + /* 덱이 비어 있는지 확인 */ + bool isEmpty = deque.Count == 0; + ``` + +=== "Go" + + ```go title="deque_test.go" + /* 덱 초기화 */ + // Go에서는 list를 덱으로 사용한다 + deque := list.New() + + /* 덱에 원소 삽입 */ + deque.PushBack(2) // 맨 뒤에 추가 + deque.PushBack(5) + deque.PushBack(4) + deque.PushFront(3) // 맨 앞에 추가 + deque.PushFront(1) + + /* 원소 접근 */ + front := deque.Front() // 맨 앞 원소 + rear := deque.Back() // 맨 뒤 원소 + + /* 덱에서 원소 꺼내기 */ + deque.Remove(front) // 맨 앞 원소 꺼내기 + deque.Remove(rear) // 맨 뒤 원소 꺼내기 + + /* 덱 길이 구하기 */ + size := deque.Len() + + /* 덱이 비어 있는지 확인 */ + isEmpty := deque.Len() == 0 + ``` + +=== "Swift" + + ```swift title="deque.swift" + /* 덱 초기화 */ + // Swift에는 내장 덱 클래스가 없으므로 Array를 덱으로 사용할 수 있다 + var deque: [Int] = [] + + /* 덱에 원소 삽입 */ + deque.append(2) // 맨 뒤에 추가 + deque.append(5) + deque.append(4) + deque.insert(3, at: 0) // 맨 앞에 추가 + deque.insert(1, at: 0) + + /* 원소 접근 */ + let peekFirst = deque.first! // 맨 앞 원소 + let peekLast = deque.last! // 맨 뒤 원소 + + /* 덱에서 원소 꺼내기 */ + // Array로 구현하면 popFirst의 복잡도는 O(n)이다 + let popFirst = deque.removeFirst() // 맨 앞 원소 꺼내기 + let popLast = deque.removeLast() // 맨 뒤 원소 꺼내기 + + /* 덱 길이 구하기 */ + let size = deque.count + + /* 덱이 비어 있는지 확인 */ + let isEmpty = deque.isEmpty + ``` + +=== "JS" + + ```javascript title="deque.js" + /* 덱 초기화 */ + // JavaScript에는 내장 덱이 없으므로 Array를 덱으로 사용해야 한다 + const deque = []; + + /* 덱에 원소 삽입 */ + deque.push(2); + deque.push(5); + deque.push(4); + // 배열이므로 unshift() 메서드의 시간 복잡도는 O(n)임에 주의 + deque.unshift(3); + deque.unshift(1); + + /* 원소 접근 */ + const peekFirst = deque[0]; + const peekLast = deque[deque.length - 1]; + + /* 덱에서 원소 꺼내기 */ + // 배열이므로 shift() 메서드의 시간 복잡도는 O(n)임에 주의 + const popFront = deque.shift(); + const popBack = deque.pop(); + + /* 덱 길이 구하기 */ + const size = deque.length; + + /* 덱이 비어 있는지 확인 */ + const isEmpty = size === 0; + ``` + +=== "TS" + + ```typescript title="deque.ts" + /* 덱 초기화 */ + // TypeScript에는 내장 덱이 없으므로 Array를 덱으로 사용해야 한다 + const deque: number[] = []; + + /* 덱에 원소 삽입 */ + deque.push(2); + deque.push(5); + deque.push(4); + // 배열이므로 unshift() 메서드의 시간 복잡도는 O(n)임에 주의 + deque.unshift(3); + deque.unshift(1); + + /* 원소 접근 */ + const peekFirst: number = deque[0]; + const peekLast: number = deque[deque.length - 1]; + + /* 덱에서 원소 꺼내기 */ + // 배열이므로 shift() 메서드의 시간 복잡도는 O(n)임에 주의 + const popFront: number = deque.shift() as number; + const popBack: number = deque.pop() as number; + + /* 덱 길이 구하기 */ + const size: number = deque.length; + + /* 덱이 비어 있는지 확인 */ + const isEmpty: boolean = size === 0; + ``` + +=== "Dart" + + ```dart title="deque.dart" + /* 덱 초기화 */ + // Dart의 Queue는 덱으로 정의되어 있다 + Queue deque = Queue(); + + /* 덱에 원소 삽입 */ + deque.addLast(2); // 맨 뒤에 추가 + deque.addLast(5); + deque.addLast(4); + deque.addFirst(3); // 맨 앞에 추가 + deque.addFirst(1); + + /* 원소 접근 */ + int peekFirst = deque.first; // 맨 앞 원소 + int peekLast = deque.last; // 맨 뒤 원소 + + /* 덱에서 원소 꺼내기 */ + int popFirst = deque.removeFirst(); // 맨 앞 원소 꺼내기 + int popLast = deque.removeLast(); // 맨 뒤 원소 꺼내기 + + /* 덱 길이 구하기 */ + int size = deque.length; + + /* 덱이 비어 있는지 확인 */ + bool isEmpty = deque.isEmpty; + ``` + +=== "Rust" + + ```rust title="deque.rs" + /* 덱 초기화 */ + let mut deque: VecDeque = VecDeque::new(); + + /* 덱에 원소 삽입 */ + deque.push_back(2); // 맨 뒤에 추가 + deque.push_back(5); + deque.push_back(4); + deque.push_front(3); // 맨 앞에 추가 + deque.push_front(1); + + /* 원소 접근 */ + if let Some(front) = deque.front() { // 맨 앞 원소 + } + if let Some(rear) = deque.back() { // 맨 뒤 원소 + } + + /* 덱에서 원소 꺼내기 */ + if let Some(pop_front) = deque.pop_front() { // 맨 앞 원소 꺼내기 + } + if let Some(pop_rear) = deque.pop_back() { // 맨 뒤 원소 꺼내기 + } + + /* 덱 길이 구하기 */ + let size = deque.len(); + + /* 덱이 비어 있는지 확인 */ + let is_empty = deque.is_empty(); + ``` + +=== "C" + + ```c title="deque.c" + // C는 내장 덱을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="deque.kt" + /* 덱 초기화 */ + val deque = LinkedList() + + /* 덱에 원소 삽입 */ + deque.offerLast(2) // 맨 뒤에 추가 + deque.offerLast(5) + deque.offerLast(4) + deque.offerFirst(3) // 맨 앞에 추가 + deque.offerFirst(1) + + /* 원소 접근 */ + val peekFirst = deque.peekFirst() // 맨 앞 원소 + val peekLast = deque.peekLast() // 맨 뒤 원소 + + /* 덱에서 원소 꺼내기 */ + val popFirst = deque.pollFirst() // 맨 앞 원소 꺼내기 + val popLast = deque.pollLast() // 맨 뒤 원소 꺼내기 + + /* 덱 길이 구하기 */ + val size = deque.size + + /* 덱이 비어 있는지 확인 */ + val isEmpty = deque.isEmpty() + ``` + +=== "Ruby" + + ```ruby title="deque.rb" + # 덱 초기화 + # Ruby에는 내장 덱이 없으므로 Array를 덱으로 사용해야 한다 + deque = [] + + # 덱에 원소 삽입 + deque << 2 + deque << 5 + deque << 4 + # 배열이므로 Array#unshift 메서드의 시간 복잡도는 O(n)임에 주의 + deque.unshift(3) + deque.unshift(1) + + # 원소 접근 + peek_first = deque.first + peek_last = deque.last + + # 덱에서 원소 꺼내기 + # 배열이므로 Array#shift 메서드의 시간 복잡도는 O(n)임에 주의 + pop_front = deque.shift + pop_back = deque.pop + + # 덱 길이 구하기 + size = deque.length + + # 덱이 비어 있는지 확인 + is_empty = size.zero? + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 5.3.2   덱 구현 * + +덱의 구현은 큐와 비슷하며, 기반 자료 구조로 연결 리스트나 배열을 선택할 수 있다. + +### 1.   이중 연결 리스트 기반 구현 + +앞 절에서는 일반적인 단일 연결 리스트로 큐를 구현했다. 머리 노드를 삭제하는 연산(큐에서 꺼내기)과 꼬리 노드 뒤에 새 노드를 추가하는 연산(큐에 삽입하기)을 쉽게 수행할 수 있기 때문이다. + +덱에서는 앞과 뒤 모두 삽입과 꺼내기를 수행할 수 있다. 즉, 큐와 대칭되는 반대 방향의 연산도 구현해야 한다. 이를 위해 ‘이중 연결 리스트’를 덱의 기반 자료 구조로 사용한다. + +아래 그림(5-8)과 같이 이중 연결 리스트의 머리 노드와 꼬리 노드를 덱의 맨 앞과 맨 뒤로 보고, 양 끝에서 노드를 추가하고 삭제하는 기능을 구현한다. + +=== "<1>" + ![연결 리스트 기반 덱의 삽입과 꺼내기](deque.assets/linkedlist_deque_step1.png){ class="animation-figure" } + +=== "<2>" + ![연결 리스트 기반 덱의 맨 뒤에 삽입](deque.assets/linkedlist_deque_step2_push_last.png){ class="animation-figure" } + +=== "<3>" + ![연결 리스트 기반 덱의 맨 앞에 삽입](deque.assets/linkedlist_deque_step3_push_first.png){ class="animation-figure" } + +=== "<4>" + ![연결 리스트 기반 덱의 맨 뒤에서 꺼내기](deque.assets/linkedlist_deque_step4_pop_last.png){ class="animation-figure" } + +=== "<5>" + ![연결 리스트 기반 덱의 맨 앞에서 꺼내기](deque.assets/linkedlist_deque_step5_pop_first.png){ class="animation-figure" } + +

그림 5-8   연결 리스트 기반 덱의 삽입과 꺼내기

+ +구현 코드는 다음과 같다. + +=== "Python" + + ```python title="linkedlist_deque.py" + class ListNode: + """이중 연결 리스트 노드""" + + def __init__(self, val: int): + """생성자""" + self.val: int = val + self.next: ListNode | None = None # 다음 노드 참조 + self.prev: ListNode | None = None # 이전 노드 참조 + + class LinkedListDeque: + """이중 연결 리스트로 구현한 덱""" + + def __init__(self): + """생성자""" + self._front: ListNode | None = None # 머리 노드 front + self._rear: ListNode | None = None # 꼬리 노드 rear + self._size: int = 0 # 덱의 길이 + + def size(self) -> int: + """덱의 길이 반환""" + return self._size + + def is_empty(self) -> bool: + """덱이 비어 있는지 판단""" + return self._size == 0 + + def push(self, num: int, is_front: bool): + """큐 삽입 연산""" + node = ListNode(num) + # 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if self.is_empty(): + self._front = self._rear = node + # 맨 앞에 삽입하는 연산 + elif is_front: + # node를 연결 리스트의 맨 앞에 추가한다 + self._front.prev = node + node.next = self._front + self._front = node # 머리 노드를 갱신한다 + # 맨 뒤에 삽입하는 연산 + else: + # node를 연결 리스트의 맨 뒤에 추가한다 + self._rear.next = node + node.prev = self._rear + self._rear = node # 꼬리 노드를 갱신한다 + self._size += 1 # 큐 길이를 갱신한다 + + def push_first(self, num: int): + """맨 앞에 삽입""" + self.push(num, True) + + def push_last(self, num: int): + """맨 뒤에 삽입""" + self.push(num, False) + + def pop(self, is_front: bool) -> int: + """큐에서 꺼내는 연산""" + if self.is_empty(): + raise IndexError("덱이 비어 있다") + # 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if self._size == 1: + val: int = self._front.val + self._front = None + self._rear = None + # 맨 앞에서 꺼내는 연산 + elif is_front: + val: int = self._front.val # 머리 노드의 값을 임시로 저장한다 + # 머리 노드를 삭제한다 + fnext: ListNode | None = self._front.next + fnext.prev = None + self._front.next = None + self._front = fnext # 머리 노드를 갱신한다 + # 맨 뒤에서 꺼내는 연산 + else: + val: int = self._rear.val # 꼬리 노드의 값을 임시로 저장한다 + # 꼬리 노드를 삭제한다 + rprev: ListNode | None = self._rear.prev + rprev.next = None + self._rear.prev = None + self._rear = rprev # 꼬리 노드를 갱신한다 + self._size -= 1 # 큐 길이를 갱신한다 + return val + + def pop_first(self) -> int: + """맨 앞에서 꺼내기""" + return self.pop(True) + + def pop_last(self) -> int: + """맨 뒤에서 꺼내기""" + return self.pop(False) + + def peek_first(self) -> int: + """맨 앞 원소 접근""" + if self.is_empty(): + raise IndexError("덱이 비어 있다") + return self._front.val + + def peek_last(self) -> int: + """맨 뒤 원소 접근""" + if self.is_empty(): + raise IndexError("덱이 비어 있다") + return self._rear.val + + def to_array(self) -> list[int]: + """출력에 사용할 배열 반환""" + node = self._front + res = [0] * self.size() + for i in range(self.size()): + res[i] = node.val + node = node.next + return res + ``` + +=== "C++" + + ```cpp title="linkedlist_deque.cpp" + /* 이중 연결 리스트 노드 */ + struct DoublyListNode { + int val; // 노드 값 + DoublyListNode *next; // 다음 노드 포인터 + DoublyListNode *prev; // 이전 노드 포인터 + DoublyListNode(int val) : val(val), prev(nullptr), next(nullptr) { + } + }; + + /* 이중 연결 리스트로 구현한 덱 */ + class LinkedListDeque { + private: + DoublyListNode *front, *rear; // 머리 노드 front, 꼬리 노드 rear + int queSize = 0; // 덱의 길이 + + public: + /* 생성자 */ + LinkedListDeque() : front(nullptr), rear(nullptr) { + } + + /* 소멸자 */ + ~LinkedListDeque() { + // 연결 리스트를 순회하며 노드를 삭제하여 메모리를 해제한다 + DoublyListNode *pre, *cur = front; + while (cur != nullptr) { + pre = cur; + cur = cur->next; + delete pre; + } + } + + /* 덱의 길이 반환 */ + int size() { + return queSize; + } + + /* 덱이 비어 있는지 판단 */ + bool isEmpty() { + return size() == 0; + } + + /* 큐 삽입 연산 */ + void push(int num, bool isFront) { + DoublyListNode *node = new DoublyListNode(num); + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if (isEmpty()) + front = rear = node; + // 맨 앞에 삽입하는 연산 + else if (isFront) { + // node를 연결 리스트의 맨 앞에 추가한다 + front->prev = node; + node->next = front; + front = node; // 머리 노드를 갱신한다 + // 맨 뒤에 삽입하는 연산 + } else { + // node를 연결 리스트의 맨 뒤에 추가한다 + rear->next = node; + node->prev = rear; + rear = node; // 꼬리 노드를 갱신한다 + } + queSize++; // 큐 길이를 갱신한다 + } + + /* 맨 앞에 삽입 */ + void pushFirst(int num) { + push(num, true); + } + + /* 맨 뒤에 삽입 */ + void pushLast(int num) { + push(num, false); + } + + /* 큐에서 꺼내는 연산 */ + int pop(bool isFront) { + if (isEmpty()) + throw out_of_range("큐가 비어 있다"); + int val; + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (queSize == 1) { + val = front->val; + delete front; // 같은 노드를 가리키므로 하나만 해제하면 된다 + front = nullptr; + rear = nullptr; + } + // 맨 앞에서 꺼내는 연산 + else if (isFront) { + val = front->val; // 머리 노드의 값을 임시로 저장한다 + // 머리 노드를 삭제한다 + DoublyListNode *fNext = front->next; + fNext->prev = nullptr; + front->next = nullptr; + delete front; + front = fNext; // 머리 노드를 갱신한다 + // 맨 뒤에서 꺼내는 연산 + } else { + val = rear->val; // 꼬리 노드의 값을 임시로 저장한다 + // 꼬리 노드를 삭제한다 + DoublyListNode *rPrev = rear->prev; + rPrev->next = nullptr; + rear->prev = nullptr; + delete rear; + rear = rPrev; // 꼬리 노드를 갱신한다 + } + queSize--; // 큐 길이를 갱신한다 + return val; + } + + /* 맨 앞에서 꺼내기 */ + int popFirst() { + return pop(true); + } + + /* 맨 뒤에서 꺼내기 */ + int popLast() { + return pop(false); + } + + /* 맨 앞 원소 접근 */ + int peekFirst() { + if (isEmpty()) + throw out_of_range("덱이 비어 있다"); + return front->val; + } + + /* 맨 뒤 원소 접근 */ + int peekLast() { + if (isEmpty()) + throw out_of_range("덱이 비어 있다"); + return rear->val; + } + + /* 출력에 사용할 배열 반환 */ + vector toVector() { + DoublyListNode *node = front; + vector res(size()); + for (int i = 0; i < res.size(); i++) { + res[i] = node->val; + node = node->next; + } + return res; + } + }; + ``` + +=== "Java" + + ```java title="linkedlist_deque.java" + /* 이중 연결 리스트 노드 */ + class ListNode { + int val; // 노드 값 + ListNode next; // 다음 노드 참조 + ListNode prev; // 이전 노드 참조 + + ListNode(int val) { + this.val = val; + prev = next = null; + } + } + + /* 이중 연결 리스트로 구현한 덱 */ + class LinkedListDeque { + private ListNode front, rear; // 머리 노드 front, 꼬리 노드 rear + private int queSize = 0; // 덱의 길이 + + public LinkedListDeque() { + front = rear = null; + } + + /* 덱의 길이 반환 */ + public int size() { + return queSize; + } + + /* 덱이 비어 있는지 판단 */ + public boolean isEmpty() { + return size() == 0; + } + + /* 큐 삽입 연산 */ + private void push(int num, boolean isFront) { + ListNode node = new ListNode(num); + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if (isEmpty()) + front = rear = node; + // 맨 앞에 삽입하는 연산 + else if (isFront) { + // node를 연결 리스트의 맨 앞에 추가한다 + front.prev = node; + node.next = front; + front = node; // 머리 노드를 갱신한다 + // 맨 뒤에 삽입하는 연산 + } else { + // node를 연결 리스트의 맨 뒤에 추가한다 + rear.next = node; + node.prev = rear; + rear = node; // 꼬리 노드를 갱신한다 + } + queSize++; // 큐 길이를 갱신한다 + } + + /* 맨 앞에 삽입 */ + public void pushFirst(int num) { + push(num, true); + } + + /* 맨 뒤에 삽입 */ + public void pushLast(int num) { + push(num, false); + } + + /* 큐에서 꺼내는 연산 */ + private int pop(boolean isFront) { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + int val; + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (queSize == 1) { + val = front.val; + front = null; + rear = null; + } + // 맨 앞에서 꺼내는 연산 + else if (isFront) { + val = front.val; // 머리 노드의 값을 임시로 저장한다 + // 머리 노드를 삭제한다 + ListNode fNext = front.next; + fNext.prev = null; + front.next = null; + front = fNext; // 머리 노드를 갱신한다 + // 맨 뒤에서 꺼내는 연산 + } else { + val = rear.val; // 꼬리 노드의 값을 임시로 저장한다 + // 꼬리 노드를 삭제한다 + ListNode rPrev = rear.prev; + rPrev.next = null; + rear.prev = null; + rear = rPrev; // 꼬리 노드를 갱신한다 + } + queSize--; // 큐 길이를 갱신한다 + return val; + } + + /* 맨 앞에서 꺼내기 */ + public int popFirst() { + return pop(true); + } + + /* 맨 뒤에서 꺼내기 */ + public int popLast() { + return pop(false); + } + + /* 맨 앞 원소 접근 */ + public int peekFirst() { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + return front.val; + } + + /* 맨 뒤 원소 접근 */ + public int peekLast() { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + return rear.val; + } + + /* 출력에 사용할 배열 반환 */ + public int[] toArray() { + ListNode node = front; + int[] res = new int[size()]; + for (int i = 0; i < res.length; i++) { + res[i] = node.val; + node = node.next; + } + return res; + } + } + ``` + +=== "C#" + + ```csharp title="linkedlist_deque.cs" + /* 이중 연결 리스트 노드 */ + class ListNode(int val) { + public int val = val; // 노드 값 + public ListNode? next = null; // 다음 노드 참조 + public ListNode? prev = null; // 이전 노드 참조 + } + + /* 이중 연결 리스트로 구현한 덱 */ + class LinkedListDeque { + ListNode? front, rear; // 머리 노드 front, 꼬리 노드 rear + int queSize = 0; // 덱의 길이 + + public LinkedListDeque() { + front = null; + rear = null; + } + + /* 덱의 길이 반환 */ + public int Size() { + return queSize; + } + + /* 덱이 비어 있는지 판단 */ + public bool IsEmpty() { + return Size() == 0; + } + + /* 큐 삽입 연산 */ + void Push(int num, bool isFront) { + ListNode node = new(num); + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if (IsEmpty()) { + front = node; + rear = node; + } + // 맨 앞에 삽입하는 연산 + else if (isFront) { + // node를 연결 리스트의 맨 앞에 추가한다 + front!.prev = node; + node.next = front; + front = node; // 머리 노드를 갱신한다 + } + // 맨 뒤에 삽입하는 연산 + else { + // node를 연결 리스트의 맨 뒤에 추가한다 + rear!.next = node; + node.prev = rear; + rear = node; // 꼬리 노드를 갱신한다 + } + + queSize++; // 큐 길이를 갱신한다 + } + + /* 맨 앞에 삽입 */ + public void PushFirst(int num) { + Push(num, true); + } + + /* 맨 뒤에 삽입 */ + public void PushLast(int num) { + Push(num, false); + } + + /* 큐에서 꺼내는 연산 */ + int? Pop(bool isFront) { + if (IsEmpty()) + throw new Exception(); + int? val; + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (queSize == 1) { + val = front!.val; + front = null; + rear = null; + } + // 맨 앞에서 꺼내는 연산 + else if (isFront) { + val = front!.val; // 머리 노드의 값을 임시로 저장한다 + // 머리 노드를 삭제한다 + ListNode? fNext = front?.next; + fNext!.prev = null; + front.next = null; + front = fNext; // 머리 노드를 갱신한다 + } + // 맨 뒤에서 꺼내는 연산 + else { + val = rear!.val; // 꼬리 노드의 값을 임시로 저장한다 + // 꼬리 노드를 삭제한다 + ListNode? rPrev = rear?.prev; + rPrev!.next = null; + rear.prev = null; + rear = rPrev; // 꼬리 노드를 갱신한다 + } + + queSize--; // 큐 길이를 갱신한다 + return val; + } + + /* 맨 앞에서 꺼내기 */ + public int? PopFirst() { + return Pop(true); + } + + /* 맨 뒤에서 꺼내기 */ + public int? PopLast() { + return Pop(false); + } + + /* 맨 앞 원소 접근 */ + public int? PeekFirst() { + if (IsEmpty()) + throw new Exception(); + return front?.val; + } + + /* 맨 뒤 원소 접근 */ + public int? PeekLast() { + if (IsEmpty()) + throw new Exception(); + return rear?.val; + } + + /* 출력에 사용할 배열 반환 */ + public int?[] ToArray() { + ListNode? node = front; + int?[] res = new int?[Size()]; + for (int i = 0; i < res.Length; i++) { + res[i] = node?.val; + node = node?.next; + } + + return res; + } + } + ``` + +=== "Go" + + ```go title="linkedlist_deque.go" + /* 이중 연결 리스트로 구현한 덱 */ + type linkedListDeque struct { + // 내장 패키지 list를 사용한다 + data *list.List + } + + /* 덱 초기화 */ + func newLinkedListDeque() *linkedListDeque { + return &linkedListDeque{ + data: list.New(), + } + } + + /* 맨 앞에 원소 삽입 */ + func (s *linkedListDeque) pushFirst(value any) { + s.data.PushFront(value) + } + + /* 맨 뒤에 원소 삽입 */ + func (s *linkedListDeque) pushLast(value any) { + s.data.PushBack(value) + } + + /* 맨 앞 원소를 꺼낸다 */ + func (s *linkedListDeque) popFirst() any { + if s.isEmpty() { + return nil + } + e := s.data.Front() + s.data.Remove(e) + return e.Value + } + + /* 맨 뒤 원소를 꺼낸다 */ + func (s *linkedListDeque) popLast() any { + if s.isEmpty() { + return nil + } + e := s.data.Back() + s.data.Remove(e) + return e.Value + } + + /* 맨 앞 원소 접근 */ + func (s *linkedListDeque) peekFirst() any { + if s.isEmpty() { + return nil + } + e := s.data.Front() + return e.Value + } + + /* 맨 뒤 원소 접근 */ + func (s *linkedListDeque) peekLast() any { + if s.isEmpty() { + return nil + } + e := s.data.Back() + return e.Value + } + + /* 큐의 길이 반환 */ + func (s *linkedListDeque) size() int { + return s.data.Len() + } + + /* 큐가 비어 있는지 판단 */ + func (s *linkedListDeque) isEmpty() bool { + return s.data.Len() == 0 + } + + /* 출력에 사용할 List 반환 */ + func (s *linkedListDeque) toList() *list.List { + return s.data + } + ``` + +=== "Swift" + + ```swift title="linkedlist_deque.swift" + /* 이중 연결 리스트 노드 */ + class ListNode { + var val: Int // 노드 값 + var next: ListNode? // 다음 노드 참조 + weak var prev: ListNode? // 이전 노드 참조 + + init(val: Int) { + self.val = val + } + } + + /* 이중 연결 리스트로 구현한 덱 */ + class LinkedListDeque { + private var front: ListNode? // 머리 노드 front + private var rear: ListNode? // 꼬리 노드 rear + private var _size: Int // 덱의 길이 + + init() { + _size = 0 + } + + /* 덱의 길이 반환 */ + func size() -> Int { + _size + } + + /* 덱이 비어 있는지 판단 */ + func isEmpty() -> Bool { + size() == 0 + } + + /* 큐 삽입 연산 */ + private func push(num: Int, isFront: Bool) { + let node = ListNode(val: num) + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if isEmpty() { + front = node + rear = node + } + // 맨 앞에 삽입하는 연산 + else if isFront { + // node를 연결 리스트의 맨 앞에 추가한다 + front?.prev = node + node.next = front + front = node // 머리 노드를 갱신한다 + } + // 맨 뒤에 삽입하는 연산 + else { + // node를 연결 리스트의 맨 뒤에 추가한다 + rear?.next = node + node.prev = rear + rear = node // 꼬리 노드를 갱신한다 + } + _size += 1 // 큐 길이를 갱신한다 + } + + /* 맨 앞에 삽입 */ + func pushFirst(num: Int) { + push(num: num, isFront: true) + } + + /* 맨 뒤에 삽입 */ + func pushLast(num: Int) { + push(num: num, isFront: false) + } + + /* 큐에서 꺼내는 연산 */ + private func pop(isFront: Bool) -> Int { + if isEmpty() { + fatalError("덱이 비어 있다") + } + let val: Int + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if _size == 1 { + val = front!.val + front = nil + rear = nil + } + // 맨 앞에서 꺼내는 연산 + else if isFront { + val = front!.val // 머리 노드의 값을 임시로 저장한다 + // 머리 노드를 삭제한다 + let fNext = front?.next + fNext?.prev = nil + front?.next = nil + front = fNext // 머리 노드를 갱신한다 + } + // 맨 뒤에서 꺼내는 연산 + else { + val = rear!.val // 꼬리 노드의 값을 임시로 저장한다 + // 꼬리 노드를 삭제한다 + let rPrev = rear?.prev + rPrev?.next = nil + rear?.prev = nil + rear = rPrev // 꼬리 노드를 갱신한다 + } + _size -= 1 // 큐 길이를 갱신한다 + return val + } + + /* 맨 앞에서 꺼내기 */ + func popFirst() -> Int { + pop(isFront: true) + } + + /* 맨 뒤에서 꺼내기 */ + func popLast() -> Int { + pop(isFront: false) + } + + /* 맨 앞 원소 접근 */ + func peekFirst() -> Int { + if isEmpty() { + fatalError("덱이 비어 있다") + } + return front!.val + } + + /* 맨 뒤 원소 접근 */ + func peekLast() -> Int { + if isEmpty() { + fatalError("덱이 비어 있다") + } + return rear!.val + } + + /* 출력에 사용할 배열 반환 */ + func toArray() -> [Int] { + var node = front + var res = Array(repeating: 0, count: size()) + for i in res.indices { + res[i] = node!.val + node = node?.next + } + return res + } + } + ``` + +=== "JS" + + ```javascript title="linkedlist_deque.js" + /* 이중 연결 리스트 노드 */ + class ListNode { + prev; // 이전 노드 참조(포인터) + next; // 다음 노드 참조(포인터) + val; // 노드 값 + + constructor(val) { + this.val = val; + this.next = null; + this.prev = null; + } + } + + /* 이중 연결 리스트로 구현한 덱 */ + class LinkedListDeque { + #front; // 머리 노드 front + #rear; // 꼬리 노드 rear + #queSize; // 덱의 길이 + + constructor() { + this.#front = null; + this.#rear = null; + this.#queSize = 0; + } + + /* 맨 뒤에 삽입하는 연산 */ + pushLast(val) { + const node = new ListNode(val); + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if (this.#queSize === 0) { + this.#front = node; + this.#rear = node; + } else { + // node를 연결 리스트의 맨 뒤에 추가한다 + this.#rear.next = node; + node.prev = this.#rear; + this.#rear = node; // 꼬리 노드를 갱신한다 + } + this.#queSize++; + } + + /* 맨 앞에 삽입하는 연산 */ + pushFirst(val) { + const node = new ListNode(val); + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if (this.#queSize === 0) { + this.#front = node; + this.#rear = node; + } else { + // node를 연결 리스트의 맨 앞에 추가한다 + this.#front.prev = node; + node.next = this.#front; + this.#front = node; // 머리 노드를 갱신한다 + } + this.#queSize++; + } + + /* 맨 뒤에서 꺼내는 연산 */ + popLast() { + if (this.#queSize === 0) { + return null; + } + const value = this.#rear.val; // 꼬리 노드의 값을 저장한다 + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (this.#queSize === 1) { + this.#front = null; + this.#rear = null; + } + // 꼬리 노드를 삭제한다 + else { + let temp = this.#rear.prev; + temp.next = null; + this.#rear.prev = null; + this.#rear = temp; // 꼬리 노드를 갱신한다 + } + this.#queSize--; + return value; + } + + /* 맨 앞에서 꺼내는 연산 */ + popFirst() { + if (this.#queSize === 0) { + return null; + } + const value = this.#front.val; // 머리 노드의 값을 저장한다 + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (this.#queSize === 1) { + this.#front = null; + this.#rear = null; + } + // 머리 노드를 삭제한다 + else { + let temp = this.#front.next; + temp.prev = null; + this.#front.next = null; + this.#front = temp; // 머리 노드를 갱신한다 + } + this.#queSize--; + return value; + } + + /* 맨 뒤 원소 접근 */ + peekLast() { + return this.#queSize === 0 ? null : this.#rear.val; + } + + /* 맨 앞 원소 접근 */ + peekFirst() { + return this.#queSize === 0 ? null : this.#front.val; + } + + /* 덱의 길이 반환 */ + size() { + return this.#queSize; + } + + /* 덱이 비어 있는지 판단 */ + isEmpty() { + return this.#queSize === 0; + } + + /* 덱을 출력한다 */ + print() { + const arr = []; + let temp = this.#front; + while (temp !== null) { + arr.push(temp.val); + temp = temp.next; + } + console.log('[' + arr.join(', ') + ']'); + } + } + ``` + +=== "TS" + + ```typescript title="linkedlist_deque.ts" + /* 이중 연결 리스트 노드 */ + class ListNode { + prev: ListNode; // 이전 노드 참조(포인터) + next: ListNode; // 다음 노드 참조(포인터) + val: number; // 노드 값 + + constructor(val: number) { + this.val = val; + this.next = null; + this.prev = null; + } + } + + /* 이중 연결 리스트로 구현한 덱 */ + class LinkedListDeque { + private front: ListNode; // 머리 노드 front + private rear: ListNode; // 꼬리 노드 rear + private queSize: number; // 덱의 길이 + + constructor() { + this.front = null; + this.rear = null; + this.queSize = 0; + } + + /* 맨 뒤에 삽입하는 연산 */ + pushLast(val: number): void { + const node: ListNode = new ListNode(val); + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if (this.queSize === 0) { + this.front = node; + this.rear = node; + } else { + // node를 연결 리스트의 맨 뒤에 추가한다 + this.rear.next = node; + node.prev = this.rear; + this.rear = node; // 꼬리 노드를 갱신한다 + } + this.queSize++; + } + + /* 맨 앞에 삽입하는 연산 */ + pushFirst(val: number): void { + const node: ListNode = new ListNode(val); + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if (this.queSize === 0) { + this.front = node; + this.rear = node; + } else { + // node를 연결 리스트의 맨 앞에 추가한다 + this.front.prev = node; + node.next = this.front; + this.front = node; // 머리 노드를 갱신한다 + } + this.queSize++; + } + + /* 맨 뒤에서 꺼내는 연산 */ + popLast(): number { + if (this.queSize === 0) { + return null; + } + const value: number = this.rear!.val; // 꼬리 노드의 값을 저장한다 + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (this.queSize === 1) { + this.front = null; + this.rear = null; + } + // 꼬리 노드를 삭제한다 + else { + let temp: ListNode = this.rear!.prev; + temp!.next = null; + this.rear!.prev = null; + this.rear = temp; // 꼬리 노드를 갱신한다 + } + this.queSize--; + return value; + } + + /* 맨 앞에서 꺼내는 연산 */ + popFirst(): number { + if (this.queSize === 0) { + return null; + } + const value: number = this.front.val; // 머리 노드의 값을 저장한다 + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (this.queSize === 1) { + this.front = null; + this.rear = null; + } + // 머리 노드를 삭제한다 + else { + let temp: ListNode = this.front!.next; + temp!.prev = null; + this.front!.next = null; + this.front = temp; // 머리 노드를 갱신한다 + } + this.queSize--; + return value; + } + + /* 맨 뒤 원소 접근 */ + peekLast(): number { + return this.queSize === 0 ? null : this.rear.val; + } + + /* 맨 앞 원소 접근 */ + peekFirst(): number { + return this.queSize === 0 ? null : this.front.val; + } + + /* 덱의 길이 반환 */ + size(): number { + return this.queSize; + } + + /* 덱이 비어 있는지 판단 */ + isEmpty(): boolean { + return this.queSize === 0; + } + + /* 덱을 출력한다 */ + print(): void { + const arr: number[] = []; + let temp: ListNode = this.front; + while (temp !== null) { + arr.push(temp.val); + temp = temp.next; + } + console.log('[' + arr.join(', ') + ']'); + } + } + ``` + +=== "Dart" + + ```dart title="linkedlist_deque.dart" + /* 이중 연결 리스트 노드 */ + class ListNode { + int val; // 노드 값 + ListNode? next; // 다음 노드 참조 + ListNode? prev; // 이전 노드 참조 + + ListNode(this.val, {this.next, this.prev}); + } + + /* 이중 연결 리스트로 구현한 덱 */ + class LinkedListDeque { + late ListNode? _front; // 머리 노드 _front + late ListNode? _rear; // 꼬리 노드 _rear + int _queSize = 0; // 덱의 길이 + + LinkedListDeque() { + this._front = null; + this._rear = null; + } + + /* 덱의 길이 반환 */ + int size() { + return this._queSize; + } + + /* 덱이 비어 있는지 판단 */ + bool isEmpty() { + return size() == 0; + } + + /* 큐 삽입 연산 */ + void push(int _num, bool isFront) { + final ListNode node = ListNode(_num); + if (isEmpty()) { + // 연결 리스트가 비어 있으면 _front와 _rear가 모두 node를 가리키게 한다 + _front = _rear = node; + } else if (isFront) { + // 맨 앞에 삽입하는 연산 + // node를 연결 리스트의 맨 앞에 추가한다 + _front!.prev = node; + node.next = _front; + _front = node; // 머리 노드를 갱신한다 + } else { + // 맨 뒤에 삽입하는 연산 + // node를 연결 리스트의 맨 뒤에 추가한다 + _rear!.next = node; + node.prev = _rear; + _rear = node; // 꼬리 노드를 갱신한다 + } + _queSize++; // 큐 길이를 갱신한다 + } + + /* 맨 앞에 삽입 */ + void pushFirst(int _num) { + push(_num, true); + } + + /* 맨 뒤에 삽입 */ + void pushLast(int _num) { + push(_num, false); + } + + /* 큐에서 꺼내는 연산 */ + int? pop(bool isFront) { + // 큐가 비어 있으면 바로 null을 반환한다 + if (isEmpty()) { + return null; + } + final int val; + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (_queSize == 1) { + val = _front!.val; + _front = null; + _rear = null; + } + // 맨 앞에서 꺼내는 연산 + else if (isFront) { + val = _front!.val; // 머리 노드의 값을 임시로 저장한다 + // 머리 노드를 삭제한다 + ListNode? fNext = _front!.next; + fNext!.prev = null; + _front!.next = null; + _front = fNext; // 머리 노드를 갱신한다 + // 맨 뒤에서 꺼내는 연산 + } else { + val = _rear!.val; // 꼬리 노드의 값을 임시로 저장한다 + // 꼬리 노드를 삭제한다 + ListNode? rPrev = _rear!.prev; + rPrev!.next = null; + _rear!.prev = null; + _rear = rPrev; // 꼬리 노드를 갱신한다 + } + _queSize--; // 큐 길이를 갱신한다 + return val; + } + + /* 맨 앞에서 꺼내기 */ + int? popFirst() { + return pop(true); + } + + /* 맨 뒤에서 꺼내기 */ + int? popLast() { + return pop(false); + } + + /* 맨 앞 원소 접근 */ + int? peekFirst() { + return _front?.val; + } + + /* 맨 뒤 원소 접근 */ + int? peekLast() { + return _rear?.val; + } + + /* 출력에 사용할 배열 반환 */ + List toArray() { + ListNode? node = _front; + final List res = []; + for (int i = 0; i < _queSize; i++) { + res.add(node!.val); + node = node.next; + } + return res; + } + } + ``` + +=== "Rust" + + ```rust title="linkedlist_deque.rs" + /* 이중 연결 리스트 노드 */ + pub struct ListNode { + pub val: T, // 노드 값 + pub next: Option>>>, // 다음 노드 포인터 + pub prev: Option>>>, // 이전 노드 포인터 + } + + impl ListNode { + pub fn new(val: T) -> Rc>> { + Rc::new(RefCell::new(ListNode { + val, + next: None, + prev: None, + })) + } + } + + /* 이중 연결 리스트로 구현한 덱 */ + #[allow(dead_code)] + pub struct LinkedListDeque { + front: Option>>>, // 머리 노드 front + rear: Option>>>, // 꼬리 노드 rear + que_size: usize, // 덱의 길이 + } + + impl LinkedListDeque { + pub fn new() -> Self { + Self { + front: None, + rear: None, + que_size: 0, + } + } + + /* 덱의 길이 반환 */ + pub fn size(&self) -> usize { + return self.que_size; + } + + /* 덱이 비어 있는지 판단 */ + pub fn is_empty(&self) -> bool { + return self.que_size == 0; + } + + /* 큐 삽입 연산 */ + fn push(&mut self, num: T, is_front: bool) { + let node = ListNode::new(num); + // 맨 앞에 삽입하는 연산 + if is_front { + match self.front.take() { + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + None => { + self.rear = Some(node.clone()); + self.front = Some(node); + } + // node를 연결 리스트의 맨 앞에 추가한다 + Some(old_front) => { + old_front.borrow_mut().prev = Some(node.clone()); + node.borrow_mut().next = Some(old_front); + self.front = Some(node); // 머리 노드를 갱신한다 + } + } + } + // 맨 뒤에 삽입하는 연산 + else { + match self.rear.take() { + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + None => { + self.front = Some(node.clone()); + self.rear = Some(node); + } + // node를 연결 리스트의 맨 뒤에 추가한다 + Some(old_rear) => { + old_rear.borrow_mut().next = Some(node.clone()); + node.borrow_mut().prev = Some(old_rear); + self.rear = Some(node); // 꼬리 노드를 갱신한다 + } + } + } + self.que_size += 1; // 큐 길이를 갱신한다 + } + + /* 맨 앞에 삽입 */ + pub fn push_first(&mut self, num: T) { + self.push(num, true); + } + + /* 맨 뒤에 삽입 */ + pub fn push_last(&mut self, num: T) { + self.push(num, false); + } + + /* 큐에서 꺼내는 연산 */ + fn pop(&mut self, is_front: bool) -> Option { + // 큐가 비어 있으면 바로 None을 반환한다 + if self.is_empty() { + return None; + }; + // 맨 앞에서 꺼내는 연산 + if is_front { + self.front.take().map(|old_front| { + match old_front.borrow_mut().next.take() { + Some(new_front) => { + new_front.borrow_mut().prev.take(); + self.front = Some(new_front); // 머리 노드를 갱신한다 + } + None => { + self.rear.take(); + } + } + self.que_size -= 1; // 큐 길이를 갱신한다 + old_front.borrow().val + }) + } + // 맨 뒤에서 꺼내는 연산 + else { + self.rear.take().map(|old_rear| { + match old_rear.borrow_mut().prev.take() { + Some(new_rear) => { + new_rear.borrow_mut().next.take(); + self.rear = Some(new_rear); // 꼬리 노드를 갱신한다 + } + None => { + self.front.take(); + } + } + self.que_size -= 1; // 큐 길이를 갱신한다 + old_rear.borrow().val + }) + } + } + + /* 맨 앞에서 꺼내기 */ + pub fn pop_first(&mut self) -> Option { + return self.pop(true); + } + + /* 맨 뒤에서 꺼내기 */ + pub fn pop_last(&mut self) -> Option { + return self.pop(false); + } + + /* 맨 앞 원소 접근 */ + pub fn peek_first(&self) -> Option<&Rc>>> { + self.front.as_ref() + } + + /* 맨 뒤 원소 접근 */ + pub fn peek_last(&self) -> Option<&Rc>>> { + self.rear.as_ref() + } + + /* 출력에 사용할 배열 반환 */ + pub fn to_array(&self, head: Option<&Rc>>>) -> Vec { + let mut res: Vec = Vec::new(); + fn recur(cur: Option<&Rc>>>, res: &mut Vec) { + if let Some(cur) = cur { + res.push(cur.borrow().val); + recur(cur.borrow().next.as_ref(), res); + } + } + + recur(head, &mut res); + res + } + } + ``` + +=== "C" + + ```c title="linkedlist_deque.c" + /* 이중 연결 리스트 노드 */ + typedef struct DoublyListNode { + int val; // 노드 값 + struct DoublyListNode *next; // 다음 노드 + struct DoublyListNode *prev; // 이전 노드 + } DoublyListNode; + + /* 생성자 */ + DoublyListNode *newDoublyListNode(int num) { + DoublyListNode *new = (DoublyListNode *)malloc(sizeof(DoublyListNode)); + new->val = num; + new->next = NULL; + new->prev = NULL; + return new; + } + + /* 소멸자 */ + void delDoublyListNode(DoublyListNode *node) { + free(node); + } + + /* 이중 연결 리스트로 구현한 덱 */ + typedef struct { + DoublyListNode *front, *rear; // 머리 노드 front, 꼬리 노드 rear + int queSize; // 덱의 길이 + } LinkedListDeque; + + /* 생성자 */ + LinkedListDeque *newLinkedListDeque() { + LinkedListDeque *deque = (LinkedListDeque *)malloc(sizeof(LinkedListDeque)); + deque->front = NULL; + deque->rear = NULL; + deque->queSize = 0; + return deque; + } + + /* 소멸자 */ + void delLinkedListdeque(LinkedListDeque *deque) { + // 모든 노드를 해제한다 + for (int i = 0; i < deque->queSize && deque->front != NULL; i++) { + DoublyListNode *tmp = deque->front; + deque->front = deque->front->next; + free(tmp); + } + // deque 구조체를 해제한다 + free(deque); + } + + /* 큐의 길이 반환 */ + int size(LinkedListDeque *deque) { + return deque->queSize; + } + + /* 큐가 비어 있는지 판단 */ + bool empty(LinkedListDeque *deque) { + return (size(deque) == 0); + } + + /* 큐에 삽입 */ + void push(LinkedListDeque *deque, int num, bool isFront) { + DoublyListNode *node = newDoublyListNode(num); + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if (empty(deque)) { + deque->front = deque->rear = node; + } + // 맨 앞에 삽입하는 연산 + else if (isFront) { + // node를 연결 리스트의 맨 앞에 추가한다 + deque->front->prev = node; + node->next = deque->front; + deque->front = node; // 머리 노드를 갱신한다 + } + // 맨 뒤에 삽입하는 연산 + else { + // node를 연결 리스트의 맨 뒤에 추가한다 + deque->rear->next = node; + node->prev = deque->rear; + deque->rear = node; + } + deque->queSize++; // 큐 길이를 갱신한다 + } + + /* 맨 앞에 삽입 */ + void pushFirst(LinkedListDeque *deque, int num) { + push(deque, num, true); + } + + /* 맨 뒤에 삽입 */ + void pushLast(LinkedListDeque *deque, int num) { + push(deque, num, false); + } + + /* 맨 앞 원소 접근 */ + int peekFirst(LinkedListDeque *deque) { + assert(size(deque) && deque->front); + return deque->front->val; + } + + /* 맨 뒤 원소 접근 */ + int peekLast(LinkedListDeque *deque) { + assert(size(deque) && deque->rear); + return deque->rear->val; + } + + /* 큐에서 꺼내기 */ + int pop(LinkedListDeque *deque, bool isFront) { + if (empty(deque)) + return -1; + int val; + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (deque->queSize == 1) { + val = deque->front->val; + delDoublyListNode(deque->front); // 같은 노드를 가리키므로 하나만 해제하면 된다 + deque->front = NULL; + deque->rear = NULL; + } + // 맨 앞에서 꺼내는 연산 + else if (isFront) { + val = peekFirst(deque); // 머리 노드의 값을 임시로 저장한다 + DoublyListNode *fNext = deque->front->next; + fNext->prev = NULL; + deque->front->next = NULL; + delDoublyListNode(deque->front); + deque->front = fNext; // 머리 노드를 갱신한다 + } + // 맨 뒤에서 꺼내는 연산 + else { + val = peekLast(deque); // 꼬리 노드의 값을 임시로 저장한다 + DoublyListNode *rPrev = deque->rear->prev; + rPrev->next = NULL; + deque->rear->prev = NULL; + delDoublyListNode(deque->rear); + deque->rear = rPrev; // 꼬리 노드를 갱신한다 + } + deque->queSize--; // 큐 길이를 갱신한다 + return val; + } + + /* 맨 앞에서 꺼내기 */ + int popFirst(LinkedListDeque *deque) { + return pop(deque, true); + } + + /* 맨 뒤에서 꺼내기 */ + int popLast(LinkedListDeque *deque) { + return pop(deque, false); + } + + /* 큐 출력 */ + void printLinkedListDeque(LinkedListDeque *deque) { + int *arr = malloc(sizeof(int) * deque->queSize); + // 연결 리스트의 데이터를 배열에 복사한다 + int i; + DoublyListNode *node; + for (i = 0, node = deque->front; i < deque->queSize; i++) { + arr[i] = node->val; + node = node->next; + } + printArray(arr, deque->queSize); + free(arr); + } + ``` + +=== "Kotlin" + + ```kotlin title="linkedlist_deque.kt" + /* 이중 연결 리스트 노드 */ + class ListNode(var _val: Int) { + // 노드 값 + var next: ListNode? = null // 다음 노드 참조 + var prev: ListNode? = null // 이전 노드 참조 + } + + /* 이중 연결 리스트로 구현한 덱 */ + class LinkedListDeque { + private var front: ListNode? = null // 머리 노드 front + private var rear: ListNode? = null // 꼬리 노드 rear + private var queSize: Int = 0 // 덱의 길이 + + /* 덱의 길이 반환 */ + fun size(): Int { + return queSize + } + + /* 덱이 비어 있는지 판단 */ + fun isEmpty(): Boolean { + return size() == 0 + } + + /* 큐 삽입 연산 */ + fun push(num: Int, isFront: Boolean) { + val node = ListNode(num) + // 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if (isEmpty()) { + rear = node + front = rear + // 맨 앞에 삽입하는 연산 + } else if (isFront) { + // node를 연결 리스트의 맨 앞에 추가한다 + front?.prev = node + node.next = front + front = node // 머리 노드를 갱신한다 + // 맨 뒤에 삽입하는 연산 + } else { + // node를 연결 리스트의 맨 뒤에 추가한다 + rear?.next = node + node.prev = rear + rear = node // 꼬리 노드를 갱신한다 + } + queSize++ // 큐 길이를 갱신한다 + } + + /* 맨 앞에 삽입 */ + fun pushFirst(num: Int) { + push(num, true) + } + + /* 맨 뒤에 삽입 */ + fun pushLast(num: Int) { + push(num, false) + } + + /* 큐에서 꺼내는 연산 */ + fun pop(isFront: Boolean): Int { + if (isEmpty()) + throw IndexOutOfBoundsException() + val _val: Int + // 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if (queSize == 1) { + _val = front!!._val + front = null + rear = null + } + // 맨 앞에서 꺼내는 연산 + else if (isFront) { + _val = front!!._val // 머리 노드의 값을 임시로 저장한다 + // 머리 노드를 삭제한다 + val fNext = front!!.next + fNext!!.prev = null + front!!.next = null + front = fNext // 머리 노드를 갱신한다 + // 맨 뒤에서 꺼내는 연산 + } else { + _val = rear!!._val // 꼬리 노드의 값을 임시로 저장한다 + // 꼬리 노드를 삭제한다 + val rPrev = rear!!.prev + rPrev!!.next = null + rear!!.prev = null + rear = rPrev // 꼬리 노드를 갱신한다 + } + queSize-- // 큐 길이를 갱신한다 + return _val + } + + /* 맨 앞에서 꺼내기 */ + fun popFirst(): Int { + return pop(true) + } + + /* 맨 뒤에서 꺼내기 */ + fun popLast(): Int { + return pop(false) + } + + /* 맨 앞 원소 접근 */ + fun peekFirst(): Int { + if (isEmpty()) throw IndexOutOfBoundsException() + return front!!._val + } + + /* 맨 뒤 원소 접근 */ + fun peekLast(): Int { + if (isEmpty()) throw IndexOutOfBoundsException() + return rear!!._val + } + + /* 출력에 사용할 배열 반환 */ + fun toArray(): IntArray { + var node = front + val res = IntArray(size()) + for (i in res.indices) { + res[i] = node!!._val + node = node.next + } + return res + } + } + ``` + +=== "Ruby" + + ```ruby title="linkedlist_deque.rb" + =begin + File: linkedlist_deque.rb + Created Time: 2024-04-06 + Author: Xuan Khoa Tu Nguyen (ngxktuzkai2000@gmail.com) + =end + + ### 이중 연결 리스트 노드 + class ListNode + attr_accessor :val + attr_accessor :next # 다음 노드 참조 + attr_accessor :prev # 이전 노드 참조 + + ### 생성자 ### + def initialize(val) + @val = val + end + end + + ### 이중 연결 리스트로 구현한 덱 ### + class LinkedListDeque + ### 덱의 길이 반환 ### + attr_reader :size + + ### 생성자 ### + def initialize + @front = nil # 머리 노드 front + @rear = nil # 꼬리 노드 rear + @size = 0 # 덱의 길이 + end + + ### 덱이 비어 있는지 판단 ### + def is_empty? + size.zero? + end + + ### 큐 삽입 연산 ### + def push(num, is_front) + node = ListNode.new(num) + # 연결 리스트가 비어 있으면 front와 rear가 모두 node를 가리키게 한다 + if is_empty? + @front = @rear = node + # 맨 앞에 삽입하는 연산 + elsif is_front + # node를 연결 리스트의 맨 앞에 추가한다 + @front.prev = node + node.next = @front + @front = node # 머리 노드를 갱신한다 + # 맨 뒤에 삽입하는 연산 + else + # node를 연결 리스트의 맨 뒤에 추가한다 + @rear.next = node + node.prev = @rear + @rear = node # 꼬리 노드를 갱신한다 + end + @size += 1 # 큐 길이를 갱신한다 + end + + ### 맨 앞에 삽입 ### + def push_first(num) + push(num, true) + end + + ### 맨 뒤에 삽입 ### + def push_last(num) + push(num, false) + end + + ### 큐에서 꺼내는 연산 ### + def pop(is_front) + raise IndexError, '덱이 비어 있다' if is_empty? + + # 큐의 길이가 1이면 머리와 꼬리가 같은 노드를 가리킨다 + if @size == 1 + val = @front.val + @front = nil + @rear = nil + # 맨 앞에서 꺼내는 연산 + elsif is_front + val = @front.val # 머리 노드의 값을 임시로 저장한다 + # 머리 노드를 삭제한다 + fnext = @front.next + fnext.prev = nil + @front.next = nil + @front = fnext # 머리 노드를 갱신한다 + # 맨 뒤에서 꺼내는 연산 + else + val = @rear.val # 꼬리 노드의 값을 임시로 저장한다 + # 꼬리 노드를 삭제한다 + rprev = @rear.prev + rprev.next = nil + @rear.prev = nil + @rear = rprev # 꼬리 노드를 갱신한다 + end + @size -= 1 # 큐 길이를 갱신한다 + + val + end + + ### 맨 앞에서 꺼내기 ### + def pop_first + pop(true) + end + + ### 맨 앞에서 꺼내기 ### + def pop_last + pop(false) + end + + ### 맨 앞 원소 접근 ### + def peek_first + raise IndexError, '덱이 비어 있다' if is_empty? + + @front.val + end + + ### 맨 뒤 원소 접근 ### + def peek_last + raise IndexError, '덱이 비어 있다' if is_empty? + + @rear.val + end + + ### 출력에 사용할 배열 반환 ### + def to_array + node = @front + res = Array.new(size, 0) + for i in 0...size + res[i] = node.val + node = node.next + end + res + end + end + ``` + +### 2.   배열 기반 구현 + +아래 그림(5-9)과 같이 배열 기반 큐와 마찬가지로 원형 배열을 사용하여 덱을 구현할 수 있다. + +=== "<1>" + ![배열 기반 덱의 삽입과 꺼내기](deque.assets/array_deque_step1.png){ class="animation-figure" } + +=== "<2>" + ![배열 기반 덱의 맨 뒤에 삽입](deque.assets/array_deque_step2_push_last.png){ class="animation-figure" } + +=== "<3>" + ![배열 기반 덱의 맨 앞에 삽입](deque.assets/array_deque_step3_push_first.png){ class="animation-figure" } + +=== "<4>" + ![배열 기반 덱의 맨 뒤에서 꺼내기](deque.assets/array_deque_step4_pop_last.png){ class="animation-figure" } + +=== "<5>" + ![배열 기반 덱의 맨 앞에서 꺼내기](deque.assets/array_deque_step5_pop_first.png){ class="animation-figure" } + +

그림 5-9   배열 기반 덱의 삽입과 꺼내기

+ +큐 구현에 ‘맨 앞에 삽입하기’와 ‘맨 뒤에서 꺼내기’ 메서드만 추가하면 된다. + +=== "Python" + + ```python title="array_deque.py" + class ArrayDeque: + """원형 배열로 구현한 덱""" + + def __init__(self, capacity: int): + """생성자""" + self._nums: list[int] = [0] * capacity + self._front: int = 0 + self._size: int = 0 + + def capacity(self) -> int: + """덱의 용량 반환""" + return len(self._nums) + + def size(self) -> int: + """덱의 길이 반환""" + return self._size + + def is_empty(self) -> bool: + """덱이 비어 있는지 판단""" + return self._size == 0 + + def index(self, i: int) -> int: + """원형 배열 인덱스 계산""" + # 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + # i가 배열의 끝을 넘어가면 처음으로 돌아온다 + # i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return (i + self.capacity()) % self.capacity() + + def push_first(self, num: int): + """맨 앞에 삽입""" + if self._size == self.capacity(): + print("덱이 가득 찼다") + return + # 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + # 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + self._front = self.index(self._front - 1) + # num을 맨 앞에 추가한다 + self._nums[self._front] = num + self._size += 1 + + def push_last(self, num: int): + """맨 뒤에 삽입""" + if self._size == self.capacity(): + print("덱이 가득 찼다") + return + # 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + rear = self.index(self._front + self._size) + # num을 맨 뒤에 추가한다 + self._nums[rear] = num + self._size += 1 + + def pop_first(self) -> int: + """맨 앞에서 꺼내기""" + num = self.peek_first() + # 맨 앞 포인터를 뒤로 한 칸 이동한다 + self._front = self.index(self._front + 1) + self._size -= 1 + return num + + def pop_last(self) -> int: + """맨 뒤에서 꺼내기""" + num = self.peek_last() + self._size -= 1 + return num + + def peek_first(self) -> int: + """맨 앞 원소 접근""" + if self.is_empty(): + raise IndexError("덱이 비어 있다") + return self._nums[self._front] + + def peek_last(self) -> int: + """맨 뒤 원소 접근""" + if self.is_empty(): + raise IndexError("덱이 비어 있다") + # 마지막 원소의 인덱스를 계산한다 + last = self.index(self._front + self._size - 1) + return self._nums[last] + + def to_array(self) -> list[int]: + """출력에 사용할 배열 반환""" + # 유효한 길이 범위 안의 리스트 원소만 변환한다 + res = [] + for i in range(self._size): + res.append(self._nums[self.index(self._front + i)]) + return res + ``` + +=== "C++" + + ```cpp title="array_deque.cpp" + /* 원형 배열로 구현한 덱 */ + class ArrayDeque { + private: + vector nums; // 덱의 원소를 저장하는 배열 + int front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + int queSize; // 덱 길이 + + public: + /* 생성자 */ + ArrayDeque(int capacity) { + nums.resize(capacity); + front = queSize = 0; + } + + /* 덱의 용량 반환 */ + int capacity() { + return nums.size(); + } + + /* 덱의 길이 반환 */ + int size() { + return queSize; + } + + /* 덱이 비어 있는지 판단 */ + bool isEmpty() { + return queSize == 0; + } + + /* 원형 배열 인덱스 계산 */ + int index(int i) { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return (i + capacity()) % capacity(); + } + + /* 맨 앞에 삽입 */ + void pushFirst(int num) { + if (queSize == capacity()) { + cout << "덱이 가득 찼다" << endl; + return; + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + front = index(front - 1); + // num을 맨 앞에 추가한다 + nums[front] = num; + queSize++; + } + + /* 맨 뒤에 삽입 */ + void pushLast(int num) { + if (queSize == capacity()) { + cout << "덱이 가득 찼다" << endl; + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + int rear = index(front + queSize); + // num을 맨 뒤에 추가한다 + nums[rear] = num; + queSize++; + } + + /* 맨 앞에서 꺼내기 */ + int popFirst() { + int num = peekFirst(); + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + front = index(front + 1); + queSize--; + return num; + } + + /* 맨 뒤에서 꺼내기 */ + int popLast() { + int num = peekLast(); + queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + int peekFirst() { + if (isEmpty()) + throw out_of_range("덱이 비어 있다"); + return nums[front]; + } + + /* 맨 뒤 원소 접근 */ + int peekLast() { + if (isEmpty()) + throw out_of_range("덱이 비어 있다"); + // 마지막 원소의 인덱스를 계산한다 + int last = index(front + queSize - 1); + return nums[last]; + } + + /* 출력에 사용할 배열 반환 */ + vector toVector() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + vector res(queSize); + for (int i = 0, j = front; i < queSize; i++, j++) { + res[i] = nums[index(j)]; + } + return res; + } + }; + ``` + +=== "Java" + + ```java title="array_deque.java" + /* 원형 배열로 구현한 덱 */ + class ArrayDeque { + private int[] nums; // 덱의 원소를 저장하는 배열 + private int front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + private int queSize; // 덱 길이 + + /* 생성자 */ + public ArrayDeque(int capacity) { + this.nums = new int[capacity]; + front = queSize = 0; + } + + /* 덱의 용량 반환 */ + public int capacity() { + return nums.length; + } + + /* 덱의 길이 반환 */ + public int size() { + return queSize; + } + + /* 덱이 비어 있는지 판단 */ + public boolean isEmpty() { + return queSize == 0; + } + + /* 원형 배열 인덱스 계산 */ + private int index(int i) { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return (i + capacity()) % capacity(); + } + + /* 맨 앞에 삽입 */ + public void pushFirst(int num) { + if (queSize == capacity()) { + System.out.println("덱이 가득 찼다"); + return; + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + front = index(front - 1); + // num을 맨 앞에 추가한다 + nums[front] = num; + queSize++; + } + + /* 맨 뒤에 삽입 */ + public void pushLast(int num) { + if (queSize == capacity()) { + System.out.println("덱이 가득 찼다"); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + int rear = index(front + queSize); + // num을 맨 뒤에 추가한다 + nums[rear] = num; + queSize++; + } + + /* 맨 앞에서 꺼내기 */ + public int popFirst() { + int num = peekFirst(); + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + front = index(front + 1); + queSize--; + return num; + } + + /* 맨 뒤에서 꺼내기 */ + public int popLast() { + int num = peekLast(); + queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + public int peekFirst() { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + return nums[front]; + } + + /* 맨 뒤 원소 접근 */ + public int peekLast() { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + // 마지막 원소의 인덱스를 계산한다 + int last = index(front + queSize - 1); + return nums[last]; + } + + /* 출력에 사용할 배열 반환 */ + public int[] toArray() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + int[] res = new int[queSize]; + for (int i = 0, j = front; i < queSize; i++, j++) { + res[i] = nums[index(j)]; + } + return res; + } + } + ``` + +=== "C#" + + ```csharp title="array_deque.cs" + /* 원형 배열로 구현한 덱 */ + class ArrayDeque { + int[] nums; // 덱의 원소를 저장하는 배열 + int front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + int queSize; // 덱 길이 + + /* 생성자 */ + public ArrayDeque(int capacity) { + nums = new int[capacity]; + front = queSize = 0; + } + + /* 덱의 용량 반환 */ + int Capacity() { + return nums.Length; + } + + /* 덱의 길이 반환 */ + public int Size() { + return queSize; + } + + /* 덱이 비어 있는지 판단 */ + public bool IsEmpty() { + return queSize == 0; + } + + /* 원형 배열 인덱스 계산 */ + int Index(int i) { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return (i + Capacity()) % Capacity(); + } + + /* 맨 앞에 삽입 */ + public void PushFirst(int num) { + if (queSize == Capacity()) { + Console.WriteLine("덱이 가득 찼다"); + return; + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + front = Index(front - 1); + // num을 맨 앞에 추가한다 + nums[front] = num; + queSize++; + } + + /* 맨 뒤에 삽입 */ + public void PushLast(int num) { + if (queSize == Capacity()) { + Console.WriteLine("덱이 가득 찼다"); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + int rear = Index(front + queSize); + // num을 맨 뒤에 추가한다 + nums[rear] = num; + queSize++; + } + + /* 맨 앞에서 꺼내기 */ + public int PopFirst() { + int num = PeekFirst(); + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + front = Index(front + 1); + queSize--; + return num; + } + + /* 맨 뒤에서 꺼내기 */ + public int PopLast() { + int num = PeekLast(); + queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + public int PeekFirst() { + if (IsEmpty()) { + throw new InvalidOperationException(); + } + return nums[front]; + } + + /* 맨 뒤 원소 접근 */ + public int PeekLast() { + if (IsEmpty()) { + throw new InvalidOperationException(); + } + // 마지막 원소의 인덱스를 계산한다 + int last = Index(front + queSize - 1); + return nums[last]; + } + + /* 출력에 사용할 배열 반환 */ + public int[] ToArray() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + int[] res = new int[queSize]; + for (int i = 0, j = front; i < queSize; i++, j++) { + res[i] = nums[Index(j)]; + } + return res; + } + } + ``` + +=== "Go" + + ```go title="array_deque.go" + /* 원형 배열로 구현한 덱 */ + type arrayDeque struct { + nums []int // 덱의 원소를 저장하는 배열 + front int // 맨 앞 원소를 가리키는 맨 앞 포인터 + queSize int // 덱 길이 + queCapacity int // 큐 용량(저장할 수 있는 최대 원소 개수) + } + + /* 큐 초기화 */ + func newArrayDeque(queCapacity int) *arrayDeque { + return &arrayDeque{ + nums: make([]int, queCapacity), + queCapacity: queCapacity, + front: 0, + queSize: 0, + } + } + + /* 덱의 길이 반환 */ + func (q *arrayDeque) size() int { + return q.queSize + } + + /* 덱이 비어 있는지 판단 */ + func (q *arrayDeque) isEmpty() bool { + return q.queSize == 0 + } + + /* 원형 배열 인덱스 계산 */ + func (q *arrayDeque) index(i int) int { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return (i + q.queCapacity) % q.queCapacity + } + + /* 맨 앞에 삽입 */ + func (q *arrayDeque) pushFirst(num int) { + if q.queSize == q.queCapacity { + fmt.Println("덱이 가득 찼다") + return + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + q.front = q.index(q.front - 1) + // num을 맨 앞에 추가한다 + q.nums[q.front] = num + q.queSize++ + } + + /* 맨 뒤에 삽입 */ + func (q *arrayDeque) pushLast(num int) { + if q.queSize == q.queCapacity { + fmt.Println("덱이 가득 찼다") + return + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + rear := q.index(q.front + q.queSize) + // num을 맨 뒤에 추가한다 + q.nums[rear] = num + q.queSize++ + } + + /* 맨 앞에서 꺼내기 */ + func (q *arrayDeque) popFirst() any { + num := q.peekFirst() + if num == nil { + return nil + } + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + q.front = q.index(q.front + 1) + q.queSize-- + return num + } + + /* 맨 뒤에서 꺼내기 */ + func (q *arrayDeque) popLast() any { + num := q.peekLast() + if num == nil { + return nil + } + q.queSize-- + return num + } + + /* 맨 앞 원소 접근 */ + func (q *arrayDeque) peekFirst() any { + if q.isEmpty() { + return nil + } + return q.nums[q.front] + } + + /* 맨 뒤 원소 접근 */ + func (q *arrayDeque) peekLast() any { + if q.isEmpty() { + return nil + } + // 마지막 원소의 인덱스를 계산한다 + last := q.index(q.front + q.queSize - 1) + return q.nums[last] + } + + /* 출력에 사용할 Slice 반환 */ + func (q *arrayDeque) toSlice() []int { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + res := make([]int, q.queSize) + for i, j := 0, q.front; i < q.queSize; i++ { + res[i] = q.nums[q.index(j)] + j++ + } + return res + } + ``` + +=== "Swift" + + ```swift title="array_deque.swift" + /* 원형 배열로 구현한 덱 */ + class ArrayDeque { + private var nums: [Int] // 덱의 원소를 저장하는 배열 + private var front: Int // 맨 앞 원소를 가리키는 맨 앞 포인터 + private var _size: Int // 덱 길이 + + /* 생성자 */ + init(capacity: Int) { + nums = Array(repeating: 0, count: capacity) + front = 0 + _size = 0 + } + + /* 덱의 용량 반환 */ + func capacity() -> Int { + nums.count + } + + /* 덱의 길이 반환 */ + func size() -> Int { + _size + } + + /* 덱이 비어 있는지 판단 */ + func isEmpty() -> Bool { + size() == 0 + } + + /* 원형 배열 인덱스 계산 */ + private func index(i: Int) -> Int { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + (i + capacity()) % capacity() + } + + /* 맨 앞에 삽입 */ + func pushFirst(num: Int) { + if size() == capacity() { + print("덱이 가득 찼다") + return + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + front = index(i: front - 1) + // num을 맨 앞에 추가한다 + nums[front] = num + _size += 1 + } + + /* 맨 뒤에 삽입 */ + func pushLast(num: Int) { + if size() == capacity() { + print("덱이 가득 찼다") + return + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + let rear = index(i: front + size()) + // num을 맨 뒤에 추가한다 + nums[rear] = num + _size += 1 + } + + /* 맨 앞에서 꺼내기 */ + func popFirst() -> Int { + let num = peekFirst() + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + front = index(i: front + 1) + _size -= 1 + return num + } + + /* 맨 뒤에서 꺼내기 */ + func popLast() -> Int { + let num = peekLast() + _size -= 1 + return num + } + + /* 맨 앞 원소 접근 */ + func peekFirst() -> Int { + if isEmpty() { + fatalError("덱이 비어 있다") + } + return nums[front] + } + + /* 맨 뒤 원소 접근 */ + func peekLast() -> Int { + if isEmpty() { + fatalError("덱이 비어 있다") + } + // 마지막 원소의 인덱스를 계산한다 + let last = index(i: front + size() - 1) + return nums[last] + } + + /* 출력에 사용할 배열 반환 */ + func toArray() -> [Int] { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + (front ..< front + size()).map { nums[index(i: $0)] } + } + } + ``` + +=== "JS" + + ```javascript title="array_deque.js" + /* 원형 배열로 구현한 덱 */ + class ArrayDeque { + #nums; // 덱의 원소를 저장하는 배열 + #front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + #queSize; // 덱 길이 + + /* 생성자 */ + constructor(capacity) { + this.#nums = new Array(capacity); + this.#front = 0; + this.#queSize = 0; + } + + /* 덱의 용량 반환 */ + capacity() { + return this.#nums.length; + } + + /* 덱의 길이 반환 */ + size() { + return this.#queSize; + } + + /* 덱이 비어 있는지 판단 */ + isEmpty() { + return this.#queSize === 0; + } + + /* 원형 배열 인덱스 계산 */ + index(i) { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return (i + this.capacity()) % this.capacity(); + } + + /* 맨 앞에 삽입 */ + pushFirst(num) { + if (this.#queSize === this.capacity()) { + console.log('덱이 가득 찼다'); + return; + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + this.#front = this.index(this.#front - 1); + // num을 맨 앞에 추가한다 + this.#nums[this.#front] = num; + this.#queSize++; + } + + /* 맨 뒤에 삽입 */ + pushLast(num) { + if (this.#queSize === this.capacity()) { + console.log('덱이 가득 찼다'); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + const rear = this.index(this.#front + this.#queSize); + // num을 맨 뒤에 추가한다 + this.#nums[rear] = num; + this.#queSize++; + } + + /* 맨 앞에서 꺼내기 */ + popFirst() { + const num = this.peekFirst(); + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + this.#front = this.index(this.#front + 1); + this.#queSize--; + return num; + } + + /* 맨 뒤에서 꺼내기 */ + popLast() { + const num = this.peekLast(); + this.#queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + peekFirst() { + if (this.isEmpty()) throw new Error('덱이 비어 있다.'); + return this.#nums[this.#front]; + } + + /* 맨 뒤 원소 접근 */ + peekLast() { + if (this.isEmpty()) throw new Error('덱이 비어 있다.'); + // 마지막 원소의 인덱스를 계산한다 + const last = this.index(this.#front + this.#queSize - 1); + return this.#nums[last]; + } + + /* 출력에 사용할 배열 반환 */ + toArray() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + const res = []; + for (let i = 0, j = this.#front; i < this.#queSize; i++, j++) { + res[i] = this.#nums[this.index(j)]; + } + return res; + } + } + ``` + +=== "TS" + + ```typescript title="array_deque.ts" + /* 원형 배열로 구현한 덱 */ + class ArrayDeque { + private nums: number[]; // 덱의 원소를 저장하는 배열 + private front: number; // 맨 앞 원소를 가리키는 맨 앞 포인터 + private queSize: number; // 덱 길이 + + /* 생성자 */ + constructor(capacity: number) { + this.nums = new Array(capacity); + this.front = 0; + this.queSize = 0; + } + + /* 덱의 용량 반환 */ + capacity(): number { + return this.nums.length; + } + + /* 덱의 길이 반환 */ + size(): number { + return this.queSize; + } + + /* 덱이 비어 있는지 판단 */ + isEmpty(): boolean { + return this.queSize === 0; + } + + /* 원형 배열 인덱스 계산 */ + index(i: number): number { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return (i + this.capacity()) % this.capacity(); + } + + /* 맨 앞에 삽입 */ + pushFirst(num: number): void { + if (this.queSize === this.capacity()) { + console.log('덱이 가득 찼다'); + return; + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + this.front = this.index(this.front - 1); + // num을 맨 앞에 추가한다 + this.nums[this.front] = num; + this.queSize++; + } + + /* 맨 뒤에 삽입 */ + pushLast(num: number): void { + if (this.queSize === this.capacity()) { + console.log('덱이 가득 찼다'); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + const rear: number = this.index(this.front + this.queSize); + // num을 맨 뒤에 추가한다 + this.nums[rear] = num; + this.queSize++; + } + + /* 맨 앞에서 꺼내기 */ + popFirst(): number { + const num: number = this.peekFirst(); + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + this.front = this.index(this.front + 1); + this.queSize--; + return num; + } + + /* 맨 뒤에서 꺼내기 */ + popLast(): number { + const num: number = this.peekLast(); + this.queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + peekFirst(): number { + if (this.isEmpty()) throw new Error('덱이 비어 있다.'); + return this.nums[this.front]; + } + + /* 맨 뒤 원소 접근 */ + peekLast(): number { + if (this.isEmpty()) throw new Error('덱이 비어 있다.'); + // 마지막 원소의 인덱스를 계산한다 + const last = this.index(this.front + this.queSize - 1); + return this.nums[last]; + } + + /* 출력에 사용할 배열 반환 */ + toArray(): number[] { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + const res: number[] = []; + for (let i = 0, j = this.front; i < this.queSize; i++, j++) { + res[i] = this.nums[this.index(j)]; + } + return res; + } + } + ``` + +=== "Dart" + + ```dart title="array_deque.dart" + /* 원형 배열로 구현한 덱 */ + class ArrayDeque { + late List _nums; // 덱의 원소를 저장하는 배열 + late int _front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + late int _queSize; // 덱 길이 + + /* 생성자 */ + ArrayDeque(int capacity) { + this._nums = List.filled(capacity, 0); + this._front = this._queSize = 0; + } + + /* 덱의 용량 반환 */ + int capacity() { + return _nums.length; + } + + /* 덱의 길이 반환 */ + int size() { + return _queSize; + } + + /* 덱이 비어 있는지 판단 */ + bool isEmpty() { + return _queSize == 0; + } + + /* 원형 배열 인덱스 계산 */ + int index(int i) { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return (i + capacity()) % capacity(); + } + + /* 맨 앞에 삽입 */ + void pushFirst(int _num) { + if (_queSize == capacity()) { + throw Exception("덱이 가득 찼다"); + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 사용하여 _front가 배열의 시작을 넘어가면 끝으로 돌아가게 한다 + _front = index(_front - 1); + // _num을 맨 앞에 추가한다 + _nums[_front] = _num; + _queSize++; + } + + /* 맨 뒤에 삽입 */ + void pushLast(int _num) { + if (_queSize == capacity()) { + throw Exception("덱이 가득 찼다"); + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + int rear = index(_front + _queSize); + // _num을 맨 뒤에 추가한다 + _nums[rear] = _num; + _queSize++; + } + + /* 맨 앞에서 꺼내기 */ + int popFirst() { + int _num = peekFirst(); + // 맨 앞 포인터를 오른쪽으로 한 칸 이동한다 + _front = index(_front + 1); + _queSize--; + return _num; + } + + /* 맨 뒤에서 꺼내기 */ + int popLast() { + int _num = peekLast(); + _queSize--; + return _num; + } + + /* 맨 앞 원소 접근 */ + int peekFirst() { + if (isEmpty()) { + throw Exception("덱이 비어 있다"); + } + return _nums[_front]; + } + + /* 맨 뒤 원소 접근 */ + int peekLast() { + if (isEmpty()) { + throw Exception("덱이 비어 있다"); + } + // 마지막 원소의 인덱스를 계산한다 + int last = index(_front + _queSize - 1); + return _nums[last]; + } + + /* 출력에 사용할 배열 반환 */ + List toArray() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + List res = List.filled(_queSize, 0); + for (int i = 0, j = _front; i < _queSize; i++, j++) { + res[i] = _nums[index(j)]; + } + return res; + } + } + ``` + +=== "Rust" + + ```rust title="array_deque.rs" + /* 원형 배열로 구현한 덱 */ + struct ArrayDeque { + nums: Vec, // 덱의 원소를 저장하는 배열 + front: usize, // 맨 앞 원소를 가리키는 맨 앞 포인터 + que_size: usize, // 덱 길이 + } + + impl ArrayDeque { + /* 생성자 */ + pub fn new(capacity: usize) -> Self { + Self { + nums: vec![T::default(); capacity], + front: 0, + que_size: 0, + } + } + + /* 덱의 용량 반환 */ + pub fn capacity(&self) -> usize { + self.nums.len() + } + + /* 덱의 길이 반환 */ + pub fn size(&self) -> usize { + self.que_size + } + + /* 덱이 비어 있는지 판단 */ + pub fn is_empty(&self) -> bool { + self.que_size == 0 + } + + /* 원형 배열 인덱스 계산 */ + fn index(&self, i: i32) -> usize { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + ((i + self.capacity() as i32) % self.capacity() as i32) as usize + } + + /* 맨 앞에 삽입 */ + pub fn push_first(&mut self, num: T) { + if self.que_size == self.capacity() { + println!("덱이 가득 찼다"); + return; + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + self.front = self.index(self.front as i32 - 1); + // num을 맨 앞에 추가한다 + self.nums[self.front] = num; + self.que_size += 1; + } + + /* 맨 뒤에 삽입 */ + pub fn push_last(&mut self, num: T) { + if self.que_size == self.capacity() { + println!("덱이 가득 찼다"); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + let rear = self.index(self.front as i32 + self.que_size as i32); + // num을 맨 뒤에 추가한다 + self.nums[rear] = num; + self.que_size += 1; + } + + /* 맨 앞에서 꺼내기 */ + fn pop_first(&mut self) -> T { + let num = self.peek_first(); + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + self.front = self.index(self.front as i32 + 1); + self.que_size -= 1; + num + } + + /* 맨 뒤에서 꺼내기 */ + fn pop_last(&mut self) -> T { + let num = self.peek_last(); + self.que_size -= 1; + num + } + + /* 맨 앞 원소 접근 */ + fn peek_first(&self) -> T { + if self.is_empty() { + panic!("덱이 비어 있다") + }; + self.nums[self.front] + } + + /* 맨 뒤 원소 접근 */ + fn peek_last(&self) -> T { + if self.is_empty() { + panic!("덱이 비어 있다") + }; + // 마지막 원소의 인덱스를 계산한다 + let last = self.index(self.front as i32 + self.que_size as i32 - 1); + self.nums[last] + } + + /* 출력에 사용할 배열 반환 */ + fn to_array(&self) -> Vec { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + let mut res = vec![T::default(); self.que_size]; + let mut j = self.front; + for i in 0..self.que_size { + res[i] = self.nums[self.index(j as i32)]; + j += 1; + } + res + } + } + ``` + +=== "C" + + ```c title="array_deque.c" + /* 원형 배열로 구현한 덱 */ + typedef struct { + int *nums; // 큐의 원소를 저장하는 배열 + int front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + int queSize; // 맨 뒤 포인터, 큐의 맨 뒤 + 1을 가리킨다 + int queCapacity; // 큐 용량 + } ArrayDeque; + + /* 생성자 */ + ArrayDeque *newArrayDeque(int capacity) { + ArrayDeque *deque = (ArrayDeque *)malloc(sizeof(ArrayDeque)); + // 배열 초기화 + deque->queCapacity = capacity; + deque->nums = (int *)malloc(sizeof(int) * deque->queCapacity); + deque->front = deque->queSize = 0; + return deque; + } + + /* 소멸자 */ + void delArrayDeque(ArrayDeque *deque) { + free(deque->nums); + free(deque); + } + + /* 덱의 용량 반환 */ + int capacity(ArrayDeque *deque) { + return deque->queCapacity; + } + + /* 덱의 길이 반환 */ + int size(ArrayDeque *deque) { + return deque->queSize; + } + + /* 덱이 비어 있는지 판단 */ + bool empty(ArrayDeque *deque) { + return deque->queSize == 0; + } + + /* 원형 배열 인덱스 계산 */ + int dequeIndex(ArrayDeque *deque, int i) { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return ((i + capacity(deque)) % capacity(deque)); + } + + /* 맨 앞에 삽입 */ + void pushFirst(ArrayDeque *deque, int num) { + if (deque->queSize == capacity(deque)) { + printf("덱이 가득 찼다\r\n"); + return; + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산으로 front가 배열의 처음을 넘어가면 끝으로 돌아가게 한다 + deque->front = dequeIndex(deque, deque->front - 1); + // num을 큐의 맨 앞에 추가한다 + deque->nums[deque->front] = num; + deque->queSize++; + } + + /* 맨 뒤에 삽입 */ + void pushLast(ArrayDeque *deque, int num) { + if (deque->queSize == capacity(deque)) { + printf("덱이 가득 찼다\r\n"); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + int rear = dequeIndex(deque, deque->front + deque->queSize); + // num을 맨 뒤에 추가한다 + deque->nums[rear] = num; + deque->queSize++; + } + + /* 맨 앞 원소 접근 */ + int peekFirst(ArrayDeque *deque) { + // 접근 예외: 덱이 비어 있다 + assert(empty(deque) == 0); + return deque->nums[deque->front]; + } + + /* 맨 뒤 원소 접근 */ + int peekLast(ArrayDeque *deque) { + // 접근 예외: 덱이 비어 있다 + assert(empty(deque) == 0); + int last = dequeIndex(deque, deque->front + deque->queSize - 1); + return deque->nums[last]; + } + + /* 맨 앞에서 꺼내기 */ + int popFirst(ArrayDeque *deque) { + int num = peekFirst(deque); + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + deque->front = dequeIndex(deque, deque->front + 1); + deque->queSize--; + return num; + } + + /* 맨 뒤에서 꺼내기 */ + int popLast(ArrayDeque *deque) { + int num = peekLast(deque); + deque->queSize--; + return num; + } + + /* 출력에 사용할 배열 반환 */ + int *toArray(ArrayDeque *deque, int *queSize) { + *queSize = deque->queSize; + int *res = (int *)calloc(deque->queSize, sizeof(int)); + int j = deque->front; + for (int i = 0; i < deque->queSize; i++) { + res[i] = deque->nums[j % deque->queCapacity]; + j++; + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="array_deque.kt" + /* 생성자 */ + class ArrayDeque(capacity: Int) { + private var nums: IntArray = IntArray(capacity) // 덱의 원소를 저장하는 배열 + private var front: Int = 0 // 맨 앞 원소를 가리키는 맨 앞 포인터 + private var queSize: Int = 0 // 덱 길이 + + /* 덱의 용량 반환 */ + fun capacity(): Int { + return nums.size + } + + /* 덱의 길이 반환 */ + fun size(): Int { + return queSize + } + + /* 덱이 비어 있는지 판단 */ + fun isEmpty(): Boolean { + return queSize == 0 + } + + /* 원형 배열 인덱스 계산 */ + private fun index(i: Int): Int { + // 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + // i가 배열의 끝을 넘어가면 처음으로 돌아온다 + // i가 배열의 처음을 넘어가면 끝으로 돌아온다 + return (i + capacity()) % capacity() + } + + /* 맨 앞에 삽입 */ + fun pushFirst(num: Int) { + if (queSize == capacity()) { + println("덱이 가득 찼다") + return + } + // 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + // 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + front = index(front - 1) + // num을 맨 앞에 추가한다 + nums[front] = num + queSize++ + } + + /* 맨 뒤에 삽입 */ + fun pushLast(num: Int) { + if (queSize == capacity()) { + println("덱이 가득 찼다") + return + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + val rear = index(front + queSize) + // num을 맨 뒤에 추가한다 + nums[rear] = num + queSize++ + } + + /* 맨 앞에서 꺼내기 */ + fun popFirst(): Int { + val num = peekFirst() + // 맨 앞 포인터를 뒤로 한 칸 이동한다 + front = index(front + 1) + queSize-- + return num + } + + /* 맨 뒤에서 꺼내기 */ + fun popLast(): Int { + val num = peekLast() + queSize-- + return num + } + + /* 맨 앞 원소 접근 */ + fun peekFirst(): Int { + if (isEmpty()) throw IndexOutOfBoundsException() + return nums[front] + } + + /* 맨 뒤 원소 접근 */ + fun peekLast(): Int { + if (isEmpty()) throw IndexOutOfBoundsException() + // 마지막 원소의 인덱스를 계산한다 + val last = index(front + queSize - 1) + return nums[last] + } + + /* 출력에 사용할 배열 반환 */ + fun toArray(): IntArray { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + val res = IntArray(queSize) + var i = 0 + var j = front + while (i < queSize) { + res[i] = nums[index(j)] + i++ + j++ + } + return res + } + } + ``` + +=== "Ruby" + + ```ruby title="array_deque.rb" + ### 원형 배열로 구현한 덱 ### + class ArrayDeque + ### 덱의 길이 반환 ### + attr_reader :size + + ### 생성자 ### + def initialize(capacity) + @nums = Array.new(capacity, 0) + @front = 0 + @size = 0 + end + + ### 덱의 용량 반환 ### + def capacity + @nums.length + end + + ### 덱이 비어 있는지 판단 ### + def is_empty? + size.zero? + end + + ### 맨 앞에 삽입 ### + def push_first(num) + if size == capacity + puts '덱이 가득 찼다' + return + end + + # 맨 앞 포인터를 왼쪽으로 한 칸 이동한다 + # 나머지 연산을 통해 front가 배열의 처음을 넘어가면 끝으로 돌아오게 한다 + @front = index(@front - 1) + # num을 맨 앞에 추가한다 + @nums[@front] = num + @size += 1 + end + + ### 맨 뒤에 삽입 ### + def push_last(num) + if size == capacity + puts '덱이 가득 찼다' + return + end + + # 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + rear = index(@front + size) + # num을 맨 뒤에 추가한다 + @nums[rear] = num + @size += 1 + end + + ### 맨 앞에서 꺼내기 ### + def pop_first + num = peek_first + # 맨 앞 포인터를 뒤로 한 칸 이동한다 + @front = index(@front + 1) + @size -= 1 + num + end + + ### 맨 뒤에서 꺼내기 ### + def pop_last + num = peek_last + @size -= 1 + num + end + + ### 맨 앞 원소 접근 ### + def peek_first + raise IndexError, '덱이 비어 있다' if is_empty? + + @nums[@front] + end + + ### 맨 뒤 원소 접근 ### + def peek_last + raise IndexError, '덱이 비어 있다' if is_empty? + + # 마지막 원소의 인덱스를 계산한다 + last = index(@front + size - 1) + @nums[last] + end + + ### 출력에 사용할 배열 반환 ### + def to_array + # 유효한 길이 범위 안의 리스트 원소만 변환한다 + res = [] + for i in 0...size + res << @nums[index(@front + i)] + end + res + end + + private + + ### 원형 배열 인덱스 계산 ### + def index(i) + # 나머지 연산을 통해 배열의 처음과 끝을 연결한다 + # i가 배열의 끝을 넘어가면 처음으로 돌아온다 + # i가 배열의 처음을 넘어가면 끝으로 돌아온다 + (i + capacity) % capacity + end + end + ``` + +## 5.3.3   덱의 활용 + +덱은 스택과 큐의 논리를 모두 갖추므로 **둘의 모든 활용 상황에 적용할 수 있으며, 더 높은 자유도도 제공한다**. + +소프트웨어의 ‘실행 취소’는 보통 스택으로 구현한다. 시스템이 각 변경 작업을 스택에 `push`하고, `pop`으로 취소한다. 다만 자원 제약 때문에 보통 취소 가능한 단계 수를 제한한다(예를 들어 $50$단계만 저장). 스택 길이가 $50$을 넘으면 바닥(덱의 맨 앞)에서 삭제해야 한다. **스택은 이 기능을 지원하지 않으므로 덱으로 대체해야 한다**. ‘실행 취소’의 핵심 논리는 여전히 스택의 후입선출 원리를 따르며, 덱은 여기에 필요한 추가 논리를 더 유연하게 구현하게 해 준다. diff --git a/ko/docs/chapter_stack_and_queue/exercises.md b/ko/docs/chapter_stack_and_queue/exercises.md new file mode 100644 index 000000000..40c5c17d8 --- /dev/null +++ b/ko/docs/chapter_stack_and_queue/exercises.md @@ -0,0 +1,103 @@ +--- +comments: true +--- + +# 5.5   연습 문제 + +## 5.5.1   개념 확인 + +### 1.   스택과 큐에서 무엇이 먼저 나오는가 + +빈 스택 `S`와 빈 큐 `Q`를 준비하고, 각각에 같은 연산을 수행한다. + +1단계: `A`를 넣는다. +2단계: `B`를 넣는다. +3단계: 원소 하나를 꺼내 기록한다. +4단계: `C`를 넣는다. +5단계: 비어 있을 때까지 원소를 계속 꺼내 기록한다. + +`S`와 `Q`에서 원소가 나오는 순서를 각각 적고, ‘후입선출’ 또는 ‘선입선출’로 차이를 설명하라. + +??? success "참고 답안" + + 스택 `S`에서 나오는 순서는 `B, C, A`다. `A, B`를 넣은 뒤 가장 최근에 넣은 `B`를 먼저 꺼내고 `C`를 넣는다. + 이후 `C, A`를 차례대로 꺼내므로 ‘후입선출’의 특성을 보인다. + + 큐 `Q`에서 나오는 순서는 `A, B, C`다. `A, B`를 넣은 뒤 가장 먼저 넣은 `A`를 꺼내고, + `C`를 넣는다. 이후 `B, C`를 차례대로 꺼내므로 ‘선입선출’의 특성을 보인다. + +### 2.   큐의 뒤가 배열의 끝을 넘어가면 어떻게 되는가 + +길이가 5인 원형 배열로 큐를 구현하며, 배열의 인덱스는 `0~4`다. +현재 `front = 3`, `size = 2`이고 큐의 `A, B`는 각각 인덱스 3과 4에 있다. + + + +1. ‘`C`를 큐에 넣기’를 수행하면 `C`를 어느 인덱스에 저장해야 하는가? 삽입 후 `size`는 얼마인가? +2. 이어서 큐에서 한 번 꺼내면 어떤 원소가 나오는가? 새로운 `front`와 `size`는 각각 얼마인가? +3. 이때 큐의 앞에서 뒤로 이어지는 논리적 순서는 무엇인가? 원소를 꺼낼 때 배열의 다른 원소도 이동해야 하는가? 이유를 설명하라. + +??? success "참고 답안" + + 1. 새 원소의 위치는 + `(front + size) % 5 = (3 + 2) % 5 = 0`이므로, + `C`는 인덱스 0에 저장된다. 삽입 후 `size = 3`이다. + + 2. 현재 큐의 맨 앞 원소인 `A`가 나온다. 새로운 맨 앞 인덱스는 + `(3 + 1) % 5 = 4`이므로 `front = 4`, `size = 2`다. + + 3. 유효한 원소의 논리적 순서는 `B, C`이며, `B`는 인덱스 4에, `C`는 인덱스 0에 있다. + 원소를 꺼낼 때는 `front`를 이동하고 `size`만 수정하면 된다. 원형 배열에서는 나머지 연산으로 인덱스가 처음으로 돌아가므로, + 다른 원소를 모두 앞으로 옮길 필요가 없다. + +### 3.   덱의 양 끝 연산 + +여기서는 `push_first`를 앞에 넣기, `push_last`를 뒤에 넣기, +`pop_first`를 앞에서 꺼내기, `pop_last`를 뒤에서 꺼내기로 정의한다. + +빈 덱 `deq`에 다음 연산을 차례대로 수행한다. + +1. `push_last(A)` +2. `push_last(B)` +3. `push_first(C)` +4. `pop_last()` +5. `push_last(D)` +6. `pop_first()` + + + +1. 두 번 꺼낸 원소는 각각 무엇인가? +2. 모든 연산을 마친 뒤 앞에서 뒤까지 어떤 원소가 남는가? +3. 뒤에서만 넣고 앞에서만 삭제할 수 있는 큐로 이 6단계를 모두 수행할 수 있는가? 불가능하다면 수행할 수 없는 연산을 찾아라. 이어서 덱은 모두 수행할 수 있는지와 그 이유를 설명하라. + +??? success "참고 답안" + + 처음 세 단계를 마치면 덱의 앞에서 뒤까지는 `[C, A, B]`다. + + + + 1. `pop_last()`로 `B`를 꺼낸다. `D`를 넣으면 `[C, A, D]`가 되고, + `pop_first()`로 `C`를 꺼낸다. + + 2. 마지막에는 `[A, D]`가 남는다. + + 3. 뒤에서만 넣고 앞에서만 삭제하는 큐로는 모든 연산을 수행할 수 없다. + 3단계의 `push_first(C)`는 앞에 넣어야 하고, 4단계의 `pop_last()`는 뒤에서 삭제해야 하므로 이 큐가 지원하는 연산 범위를 벗어난다. + 덱은 양 끝에서 모두 삽입과 삭제가 가능하므로 6단계를 전부 수행할 수 있다. + +## 5.5.2   프로그래밍 연습 + +### 1.   괄호 문자열 검사하기 + +세 종류의 괄호 `()`, `[]`, `{}`만 포함하는 문자열 `s`가 주어진다. 스택을 사용하여 올바른 괄호 문자열인지 판단하라. + +올바른 문자열은 다음 두 조건을 모두 만족해야 한다. 닫는 괄호는 가장 최근에 등장한, 아직 짝이 없는 여는 괄호와 종류가 같아야 하며, +순회를 마친 뒤 짝이 없는 여는 괄호가 남아 있어서는 안 된다. 판단 결과를 불리언 값으로 반환하라. + +??? tip "풀이 힌트" + + 1. ‘닫는 괄호에서 대응하는 여는 괄호로의 매핑’을 만들 수 있다. + 2. 닫는 괄호를 만나면 먼저 스택이 비었는지 확인한 뒤 맨 위 괄호가 짝이 맞는지 확인한다. + 3. 순회가 끝난 뒤에도 스택이 비어 있어야 한다. + +[LeetCode](https://leetcode.com/problems/valid-parentheses/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_stack_and_queue/index.md b/ko/docs/chapter_stack_and_queue/index.md new file mode 100644 index 000000000..00a66de01 --- /dev/null +++ b/ko/docs/chapter_stack_and_queue/index.md @@ -0,0 +1,22 @@ +--- +comments: true +icon: material/stack-overflow +--- + +# 제 5 장   스택과 큐 + +![스택과 큐](../assets/covers/chapter_stack_and_queue.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 스택은 고양이들이 포개져 있는 모습과 같고, 큐는 고양이들이 줄을 서 있는 모습과 같다. + + 둘은 각각 후입선출과 선입선출의 논리적 관계를 나타낸다. + +## 이 장의 내용 + +- [5.1   스택](stack.md) +- [5.2   큐](queue.md) +- [5.3   덱](deque.md) +- [5.4   요약](summary.md) +- [5.5   연습 문제](exercises.md) diff --git a/ko/docs/chapter_stack_and_queue/queue.md b/ko/docs/chapter_stack_and_queue/queue.md new file mode 100644 index 000000000..de9629a42 --- /dev/null +++ b/ko/docs/chapter_stack_and_queue/queue.md @@ -0,0 +1,2301 @@ +--- +comments: true +--- + +# 5.2   큐 + +큐(queue)는 선입선출 규칙을 따르는 선형 자료 구조다. 이름 그대로 줄 서는 현상을 재현한다. 새로 온 사람은 계속 줄의 뒤에 서고, 맨 앞에 있는 사람부터 하나씩 떠난다. + +아래 그림(5-4)과 같이 큐의 앞쪽을 ‘큐의 맨 앞’, 뒤쪽을 ‘큐의 맨 뒤’라고 한다. 맨 뒤에 원소를 추가하는 연산을 ‘큐에 삽입하기’, 맨 앞 원소를 삭제하는 연산을 ‘큐에서 꺼내기’라고 한다. + +![큐의 선입선출 규칙](queue.assets/queue_operations.png){ class="animation-figure" } + +

그림 5-4   큐의 선입선출 규칙

+ +## 5.2.1   큐의 기본 연산 + +큐의 기본 연산은 아래 표(5-2)와 같다. 메서드 이름은 프로그래밍 언어에 따라 다를 수 있으며, 여기서는 스택과 같은 이름을 사용한다. + +

표 5-2   큐의 연산 효율

+ +
+ +| 메서드 | 설명 | 시간 복잡도 | +| -------- | ---------------------------- | ---------- | +| `push()` | 큐에 원소 삽입, 즉 맨 뒤에 추가 | $O(1)$ | +| `pop()` | 큐의 맨 앞 원소를 꺼냄 | $O(1)$ | +| `peek()` | 큐의 맨 앞 원소에 접근 | $O(1)$ | + +
+ +프로그래밍 언어가 제공하는 큐 클래스를 바로 사용할 수 있다. + +=== "Python" + + ```python title="queue.py" + from collections import deque + + # 큐 초기화 + # Python에서는 보통 덱 클래스 deque를 큐로 사용한다 + # queue.Queue()는 전용 큐 클래스이지만 사용이 불편하므로 권하지 않는다 + que: deque[int] = deque() + + # 큐에 원소 삽입 + que.append(1) + que.append(3) + que.append(2) + que.append(5) + que.append(4) + + # 큐의 맨 앞 원소에 접근 + front: int = que[0] + + # 큐에서 원소 꺼내기 + pop: int = que.popleft() + + # 큐 길이 구하기 + size: int = len(que) + + # 큐가 비어 있는지 확인 + is_empty: bool = len(que) == 0 + ``` + +=== "C++" + + ```cpp title="queue.cpp" + /* 큐 초기화 */ + queue queue; + + /* 큐에 원소 삽입 */ + queue.push(1); + queue.push(3); + queue.push(2); + queue.push(5); + queue.push(4); + + /* 큐의 맨 앞 원소에 접근 */ + int front = queue.front(); + + /* 큐에서 원소 꺼내기 */ + queue.pop(); + + /* 큐 길이 구하기 */ + int size = queue.size(); + + /* 큐가 비어 있는지 확인 */ + bool empty = queue.empty(); + ``` + +=== "Java" + + ```java title="queue.java" + /* 큐 초기화 */ + Queue queue = new LinkedList<>(); + + /* 큐에 원소 삽입 */ + queue.offer(1); + queue.offer(3); + queue.offer(2); + queue.offer(5); + queue.offer(4); + + /* 큐의 맨 앞 원소에 접근 */ + int peek = queue.peek(); + + /* 큐에서 원소 꺼내기 */ + int pop = queue.poll(); + + /* 큐 길이 구하기 */ + int size = queue.size(); + + /* 큐가 비어 있는지 확인 */ + boolean isEmpty = queue.isEmpty(); + ``` + +=== "C#" + + ```csharp title="queue.cs" + /* 큐 초기화 */ + Queue queue = new(); + + /* 큐에 원소 삽입 */ + queue.Enqueue(1); + queue.Enqueue(3); + queue.Enqueue(2); + queue.Enqueue(5); + queue.Enqueue(4); + + /* 큐의 맨 앞 원소에 접근 */ + int peek = queue.Peek(); + + /* 큐에서 원소 꺼내기 */ + int pop = queue.Dequeue(); + + /* 큐 길이 구하기 */ + int size = queue.Count; + + /* 큐가 비어 있는지 확인 */ + bool isEmpty = queue.Count == 0; + ``` + +=== "Go" + + ```go title="queue_test.go" + /* 큐 초기화 */ + // Go에서는 list를 큐로 사용한다 + queue := list.New() + + /* 큐에 원소 삽입 */ + queue.PushBack(1) + queue.PushBack(3) + queue.PushBack(2) + queue.PushBack(5) + queue.PushBack(4) + + /* 큐의 맨 앞 원소에 접근 */ + peek := queue.Front() + + /* 큐에서 원소 꺼내기 */ + pop := queue.Front() + queue.Remove(pop) + + /* 큐 길이 구하기 */ + size := queue.Len() + + /* 큐가 비어 있는지 확인 */ + isEmpty := queue.Len() == 0 + ``` + +=== "Swift" + + ```swift title="queue.swift" + /* 큐 초기화 */ + // Swift에는 내장 큐 클래스가 없으므로 Array를 큐로 사용할 수 있다 + var queue: [Int] = [] + + /* 큐에 원소 삽입 */ + queue.append(1) + queue.append(3) + queue.append(2) + queue.append(5) + queue.append(4) + + /* 큐의 맨 앞 원소에 접근 */ + let peek = queue.first! + + /* 큐에서 원소 꺼내기 */ + // 배열이므로 removeFirst의 복잡도는 O(n)이다 + let pop = queue.removeFirst() + + /* 큐 길이 구하기 */ + let size = queue.count + + /* 큐가 비어 있는지 확인 */ + let isEmpty = queue.isEmpty + ``` + +=== "JS" + + ```javascript title="queue.js" + /* 큐 초기화 */ + // JavaScript에는 내장 큐가 없으므로 Array를 큐로 사용할 수 있다 + const queue = []; + + /* 큐에 원소 삽입 */ + queue.push(1); + queue.push(3); + queue.push(2); + queue.push(5); + queue.push(4); + + /* 큐의 맨 앞 원소에 접근 */ + const peek = queue[0]; + + /* 큐에서 원소 꺼내기 */ + // 기반이 배열이므로 shift() 메서드의 시간 복잡도는 O(n)이다 + const pop = queue.shift(); + + /* 큐 길이 구하기 */ + const size = queue.length; + + /* 큐가 비어 있는지 확인 */ + const empty = queue.length === 0; + ``` + +=== "TS" + + ```typescript title="queue.ts" + /* 큐 초기화 */ + // TypeScript에는 내장 큐가 없으므로 Array를 큐로 사용할 수 있다 + const queue: number[] = []; + + /* 큐에 원소 삽입 */ + queue.push(1); + queue.push(3); + queue.push(2); + queue.push(5); + queue.push(4); + + /* 큐의 맨 앞 원소에 접근 */ + const peek = queue[0]; + + /* 큐에서 원소 꺼내기 */ + // 기반이 배열이므로 shift() 메서드의 시간 복잡도는 O(n)이다 + const pop = queue.shift(); + + /* 큐 길이 구하기 */ + const size = queue.length; + + /* 큐가 비어 있는지 확인 */ + const empty = queue.length === 0; + ``` + +=== "Dart" + + ```dart title="queue.dart" + /* 큐 초기화 */ + // Dart의 Queue 클래스는 덱이며 일반 큐로도 사용할 수 있다 + Queue queue = Queue(); + + /* 큐에 원소 삽입 */ + queue.add(1); + queue.add(3); + queue.add(2); + queue.add(5); + queue.add(4); + + /* 큐의 맨 앞 원소에 접근 */ + int peek = queue.first; + + /* 큐에서 원소 꺼내기 */ + int pop = queue.removeFirst(); + + /* 큐 길이 구하기 */ + int size = queue.length; + + /* 큐가 비어 있는지 확인 */ + bool isEmpty = queue.isEmpty; + ``` + +=== "Rust" + + ```rust title="queue.rs" + /* 덱 초기화 */ + // Rust에서는 덱을 일반 큐로 사용한다 + let mut deque: VecDeque = VecDeque::new(); + + /* 큐에 원소 삽입 */ + deque.push_back(1); + deque.push_back(3); + deque.push_back(2); + deque.push_back(5); + deque.push_back(4); + + /* 큐의 맨 앞 원소에 접근 */ + if let Some(front) = deque.front() { + } + + /* 큐에서 원소 꺼내기 */ + if let Some(pop) = deque.pop_front() { + } + + /* 큐 길이 구하기 */ + let size = deque.len(); + + /* 큐가 비어 있는지 확인 */ + let is_empty = deque.is_empty(); + ``` + +=== "C" + + ```c title="queue.c" + // C는 내장 큐를 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="queue.kt" + /* 큐 초기화 */ + val queue = LinkedList() + + /* 큐에 원소 삽입 */ + queue.offer(1) + queue.offer(3) + queue.offer(2) + queue.offer(5) + queue.offer(4) + + /* 큐의 맨 앞 원소에 접근 */ + val peek = queue.peek() + + /* 큐에서 원소 꺼내기 */ + val pop = queue.poll() + + /* 큐 길이 구하기 */ + val size = queue.size + + /* 큐가 비어 있는지 확인 */ + val isEmpty = queue.isEmpty() + ``` + +=== "Ruby" + + ```ruby title="queue.rb" + # 큐 초기화 + # Ruby의 내장 큐(Thread::Queue)에는 peek와 순회 메서드가 없으므로 Array를 큐로 사용할 수 있다 + queue = [] + + # 큐에 원소 삽입 + queue.push(1) + queue.push(3) + queue.push(2) + queue.push(5) + queue.push(4) + + # 큐 원소에 접근 + peek = queue.first + + # 큐에서 원소 꺼내기 + # 배열이므로 Array#shift 메서드의 시간 복잡도는 O(n)임에 주의 + pop = queue.shift + + # 큐 길이 구하기 + size = queue.length + + # 큐가 비어 있는지 확인 + is_empty = queue.empty? + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 5.2.2   큐 구현 + +큐를 구현하려면 한쪽 끝에서 원소를 추가하고 반대쪽 끝에서 삭제할 수 있는 자료 구조가 필요하다. 연결 리스트와 배열 모두 이 조건을 만족한다. + +### 1.   연결 리스트 기반 구현 + +아래 그림(5-5)과 같이 연결 리스트의 ‘머리 노드’와 ‘꼬리 노드’를 각각 ‘큐의 맨 앞’과 ‘큐의 맨 뒤’로 볼 수 있다. 맨 뒤에서는 노드 추가만, 맨 앞에서는 노드 삭제만 허용한다. + +=== "<1>" + ![연결 리스트 기반 큐의 삽입과 꺼내기](queue.assets/linkedlist_queue_step1.png){ class="animation-figure" } + +=== "<2>" + ![연결 리스트 기반 큐에 원소 삽입](queue.assets/linkedlist_queue_step2_push.png){ class="animation-figure" } + +=== "<3>" + ![연결 리스트 기반 큐에서 원소 꺼내기](queue.assets/linkedlist_queue_step3_pop.png){ class="animation-figure" } + +

그림 5-5   연결 리스트 기반 큐의 삽입과 꺼내기

+ +연결 리스트로 큐를 구현한 코드는 다음과 같다. + +=== "Python" + + ```python title="linkedlist_queue.py" + class LinkedListQueue: + """연결 리스트로 구현한 큐""" + + def __init__(self): + """생성자""" + self._front: ListNode | None = None # 머리 노드 front + self._rear: ListNode | None = None # 꼬리 노드 rear + self._size: int = 0 + + def size(self) -> int: + """큐의 길이 반환""" + return self._size + + def is_empty(self) -> bool: + """큐가 비어 있는지 판단""" + return self._size == 0 + + def push(self, num: int): + """큐에 삽입""" + # 꼬리 노드 뒤에 num을 추가한다 + node = ListNode(num) + # 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if self._front is None: + self._front = node + self._rear = node + # 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + else: + self._rear.next = node + self._rear = node + self._size += 1 + + def pop(self) -> int: + """큐에서 꺼내기""" + num = self.peek() + # 머리 노드를 삭제한다 + self._front = self._front.next + self._size -= 1 + return num + + def peek(self) -> int: + """맨 앞 원소 접근""" + if self.is_empty(): + raise IndexError("큐가 비어 있다") + return self._front.val + + def to_list(self) -> list[int]: + """출력에 사용할 리스트로 변환""" + queue = [] + temp = self._front + while temp: + queue.append(temp.val) + temp = temp.next + return queue + ``` + +=== "C++" + + ```cpp title="linkedlist_queue.cpp" + /* 연결 리스트로 구현한 큐 */ + class LinkedListQueue { + private: + ListNode *front, *rear; // 머리 노드 front, 꼬리 노드 rear + int queSize; + + public: + LinkedListQueue() { + front = nullptr; + rear = nullptr; + queSize = 0; + } + + ~LinkedListQueue() { + // 연결 리스트를 순회하며 노드를 삭제하여 메모리를 해제한다 + freeMemoryLinkedList(front); + } + + /* 큐의 길이 반환 */ + int size() { + return queSize; + } + + /* 큐가 비어 있는지 판단 */ + bool isEmpty() { + return queSize == 0; + } + + /* 큐에 삽입 */ + void push(int num) { + // 꼬리 노드 뒤에 num을 추가한다 + ListNode *node = new ListNode(num); + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if (front == nullptr) { + front = node; + rear = node; + } + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + else { + rear->next = node; + rear = node; + } + queSize++; + } + + /* 큐에서 꺼내기 */ + int pop() { + int num = peek(); + // 머리 노드를 삭제한다 + ListNode *tmp = front; + front = front->next; + // 메모리 해제 + delete tmp; + queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + int peek() { + if (size() == 0) + throw out_of_range("큐가 비어 있다"); + return front->val; + } + + /* 연결 리스트를 Vector로 변환하여 반환 */ + vector toVector() { + ListNode *node = front; + vector res(size()); + for (int i = 0; i < res.size(); i++) { + res[i] = node->val; + node = node->next; + } + return res; + } + }; + ``` + +=== "Java" + + ```java title="linkedlist_queue.java" + /* 연결 리스트로 구현한 큐 */ + class LinkedListQueue { + private ListNode front, rear; // 머리 노드 front, 꼬리 노드 rear + private int queSize = 0; + + public LinkedListQueue() { + front = null; + rear = null; + } + + /* 큐의 길이 반환 */ + public int size() { + return queSize; + } + + /* 큐가 비어 있는지 판단 */ + public boolean isEmpty() { + return size() == 0; + } + + /* 큐에 삽입 */ + public void push(int num) { + // 꼬리 노드 뒤에 num을 추가한다 + ListNode node = new ListNode(num); + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if (front == null) { + front = node; + rear = node; + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + } else { + rear.next = node; + rear = node; + } + queSize++; + } + + /* 큐에서 꺼내기 */ + public int pop() { + int num = peek(); + // 머리 노드를 삭제한다 + front = front.next; + queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + public int peek() { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + return front.val; + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + public int[] toArray() { + ListNode node = front; + int[] res = new int[size()]; + for (int i = 0; i < res.length; i++) { + res[i] = node.val; + node = node.next; + } + return res; + } + } + ``` + +=== "C#" + + ```csharp title="linkedlist_queue.cs" + /* 연결 리스트로 구현한 큐 */ + class LinkedListQueue { + ListNode? front, rear; // 머리 노드 front, 꼬리 노드 rear + int queSize = 0; + + public LinkedListQueue() { + front = null; + rear = null; + } + + /* 큐의 길이 반환 */ + public int Size() { + return queSize; + } + + /* 큐가 비어 있는지 판단 */ + public bool IsEmpty() { + return Size() == 0; + } + + /* 큐에 삽입 */ + public void Push(int num) { + // 꼬리 노드 뒤에 num을 추가한다 + ListNode node = new(num); + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if (front == null) { + front = node; + rear = node; + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + } else if (rear != null) { + rear.next = node; + rear = node; + } + queSize++; + } + + /* 큐에서 꺼내기 */ + public int Pop() { + int num = Peek(); + // 머리 노드를 삭제한다 + front = front?.next; + queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + public int Peek() { + if (IsEmpty()) + throw new Exception(); + return front!.val; + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + public int[] ToArray() { + if (front == null) + return []; + + ListNode? node = front; + int[] res = new int[Size()]; + for (int i = 0; i < res.Length; i++) { + res[i] = node!.val; + node = node.next; + } + return res; + } + } + ``` + +=== "Go" + + ```go title="linkedlist_queue.go" + /* 연결 리스트로 구현한 큐 */ + type linkedListQueue struct { + // 내장 패키지 list로 큐를 구현한다 + data *list.List + } + + /* 큐 초기화 */ + func newLinkedListQueue() *linkedListQueue { + return &linkedListQueue{ + data: list.New(), + } + } + + /* 큐에 삽입 */ + func (s *linkedListQueue) push(value any) { + s.data.PushBack(value) + } + + /* 큐에서 꺼내기 */ + func (s *linkedListQueue) pop() any { + if s.isEmpty() { + return nil + } + e := s.data.Front() + s.data.Remove(e) + return e.Value + } + + /* 맨 앞 원소 접근 */ + func (s *linkedListQueue) peek() any { + if s.isEmpty() { + return nil + } + e := s.data.Front() + return e.Value + } + + /* 큐의 길이 반환 */ + func (s *linkedListQueue) size() int { + return s.data.Len() + } + + /* 큐가 비어 있는지 판단 */ + func (s *linkedListQueue) isEmpty() bool { + return s.data.Len() == 0 + } + + /* 출력에 사용할 List 반환 */ + func (s *linkedListQueue) toList() *list.List { + return s.data + } + ``` + +=== "Swift" + + ```swift title="linkedlist_queue.swift" + /* 연결 리스트로 구현한 큐 */ + class LinkedListQueue { + private var front: ListNode? // 머리 노드 + private var rear: ListNode? // 꼬리 노드 + private var _size: Int + + init() { + _size = 0 + } + + /* 큐의 길이 반환 */ + func size() -> Int { + _size + } + + /* 큐가 비어 있는지 판단 */ + func isEmpty() -> Bool { + size() == 0 + } + + /* 큐에 삽입 */ + func push(num: Int) { + // 꼬리 노드 뒤에 num을 추가한다 + let node = ListNode(x: num) + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if front == nil { + front = node + rear = node + } + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + else { + rear?.next = node + rear = node + } + _size += 1 + } + + /* 큐에서 꺼내기 */ + @discardableResult + func pop() -> Int { + let num = peek() + // 머리 노드를 삭제한다 + front = front?.next + _size -= 1 + return num + } + + /* 맨 앞 원소 접근 */ + func peek() -> Int { + if isEmpty() { + fatalError("큐가 비어 있다") + } + return front!.val + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + func toArray() -> [Int] { + var node = front + var res = Array(repeating: 0, count: size()) + for i in res.indices { + res[i] = node!.val + node = node?.next + } + return res + } + } + ``` + +=== "JS" + + ```javascript title="linkedlist_queue.js" + /* 연결 리스트로 구현한 큐 */ + class LinkedListQueue { + #front; // 머리 노드 #front + #rear; // 꼬리 노드 #rear + #queSize = 0; + + constructor() { + this.#front = null; + this.#rear = null; + } + + /* 큐의 길이 반환 */ + get size() { + return this.#queSize; + } + + /* 큐가 비어 있는지 판단 */ + isEmpty() { + return this.size === 0; + } + + /* 큐에 삽입 */ + push(num) { + // 꼬리 노드 뒤에 num을 추가한다 + const node = new ListNode(num); + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if (!this.#front) { + this.#front = node; + this.#rear = node; + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + } else { + this.#rear.next = node; + this.#rear = node; + } + this.#queSize++; + } + + /* 큐에서 꺼내기 */ + pop() { + const num = this.peek(); + // 머리 노드를 삭제한다 + this.#front = this.#front.next; + this.#queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + peek() { + if (this.size === 0) throw new Error('큐가 비어 있다'); + return this.#front.val; + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + toArray() { + let node = this.#front; + const res = new Array(this.size); + for (let i = 0; i < res.length; i++) { + res[i] = node.val; + node = node.next; + } + return res; + } + } + ``` + +=== "TS" + + ```typescript title="linkedlist_queue.ts" + /* 연결 리스트로 구현한 큐 */ + class LinkedListQueue { + private front: ListNode | null; // 머리 노드 front + private rear: ListNode | null; // 꼬리 노드 rear + private queSize: number = 0; + + constructor() { + this.front = null; + this.rear = null; + } + + /* 큐의 길이 반환 */ + get size(): number { + return this.queSize; + } + + /* 큐가 비어 있는지 판단 */ + isEmpty(): boolean { + return this.size === 0; + } + + /* 큐에 삽입 */ + push(num: number): void { + // 꼬리 노드 뒤에 num을 추가한다 + const node = new ListNode(num); + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if (!this.front) { + this.front = node; + this.rear = node; + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + } else { + this.rear!.next = node; + this.rear = node; + } + this.queSize++; + } + + /* 큐에서 꺼내기 */ + pop(): number { + const num = this.peek(); + if (!this.front) throw new Error('큐가 비어 있다'); + // 머리 노드를 삭제한다 + this.front = this.front.next; + this.queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + peek(): number { + if (this.size === 0) throw new Error('큐가 비어 있다'); + return this.front!.val; + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + toArray(): number[] { + let node = this.front; + const res = new Array(this.size); + for (let i = 0; i < res.length; i++) { + res[i] = node!.val; + node = node!.next; + } + return res; + } + } + ``` + +=== "Dart" + + ```dart title="linkedlist_queue.dart" + /* 연결 리스트로 구현한 큐 */ + class LinkedListQueue { + ListNode? _front; // 머리 노드 _front + ListNode? _rear; // 꼬리 노드 _rear + int _queSize = 0; // 큐 길이 + + LinkedListQueue() { + _front = null; + _rear = null; + } + + /* 큐의 길이 반환 */ + int size() { + return _queSize; + } + + /* 큐가 비어 있는지 판단 */ + bool isEmpty() { + return _queSize == 0; + } + + /* 큐에 삽입 */ + void push(int _num) { + // 꼬리 노드 뒤에 _num을 추가한다 + final node = ListNode(_num); + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if (_front == null) { + _front = node; + _rear = node; + } else { + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + _rear!.next = node; + _rear = node; + } + _queSize++; + } + + /* 큐에서 꺼내기 */ + int pop() { + final int _num = peek(); + // 머리 노드를 삭제한다 + _front = _front!.next; + _queSize--; + return _num; + } + + /* 맨 앞 원소 접근 */ + int peek() { + if (_queSize == 0) { + throw Exception('큐가 비어 있다'); + } + return _front!.val; + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + List toArray() { + ListNode? node = _front; + final List queue = []; + while (node != null) { + queue.add(node.val); + node = node.next; + } + return queue; + } + } + ``` + +=== "Rust" + + ```rust title="linkedlist_queue.rs" + /* 연결 리스트로 구현한 큐 */ + #[allow(dead_code)] + pub struct LinkedListQueue { + front: Option>>>, // 머리 노드 front + rear: Option>>>, // 꼬리 노드 rear + que_size: usize, // 큐의 길이 + } + + impl LinkedListQueue { + pub fn new() -> Self { + Self { + front: None, + rear: None, + que_size: 0, + } + } + + /* 큐의 길이 반환 */ + pub fn size(&self) -> usize { + return self.que_size; + } + + /* 큐가 비어 있는지 판단 */ + pub fn is_empty(&self) -> bool { + return self.que_size == 0; + } + + /* 큐에 삽입 */ + pub fn push(&mut self, num: T) { + // 꼬리 노드 뒤에 num을 추가한다 + let new_rear = ListNode::new(num); + match self.rear.take() { + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + Some(old_rear) => { + old_rear.borrow_mut().next = Some(new_rear.clone()); + self.rear = Some(new_rear); + } + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + None => { + self.front = Some(new_rear.clone()); + self.rear = Some(new_rear); + } + } + self.que_size += 1; + } + + /* 큐에서 꺼내기 */ + pub fn pop(&mut self) -> Option { + self.front.take().map(|old_front| { + match old_front.borrow_mut().next.take() { + Some(new_front) => { + self.front = Some(new_front); + } + None => { + self.rear.take(); + } + } + self.que_size -= 1; + old_front.borrow().val + }) + } + + /* 맨 앞 원소 접근 */ + pub fn peek(&self) -> Option<&Rc>>> { + self.front.as_ref() + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + pub fn to_array(&self, head: Option<&Rc>>>) -> Vec { + let mut res: Vec = Vec::new(); + + fn recur(cur: Option<&Rc>>>, res: &mut Vec) { + if let Some(cur) = cur { + res.push(cur.borrow().val); + recur(cur.borrow().next.as_ref(), res); + } + } + + recur(head, &mut res); + + res + } + } + ``` + +=== "C" + + ```c title="linkedlist_queue.c" + /* 연결 리스트로 구현한 큐 */ + typedef struct { + ListNode *front, *rear; + int queSize; + } LinkedListQueue; + + /* 생성자 */ + LinkedListQueue *newLinkedListQueue() { + LinkedListQueue *queue = (LinkedListQueue *)malloc(sizeof(LinkedListQueue)); + queue->front = NULL; + queue->rear = NULL; + queue->queSize = 0; + return queue; + } + + /* 소멸자 */ + void delLinkedListQueue(LinkedListQueue *queue) { + // 모든 노드를 해제한다 + while (queue->front != NULL) { + ListNode *tmp = queue->front; + queue->front = queue->front->next; + free(tmp); + } + // queue 구조체를 해제한다 + free(queue); + } + + /* 큐의 길이 반환 */ + int size(LinkedListQueue *queue) { + return queue->queSize; + } + + /* 큐가 비어 있는지 판단 */ + bool empty(LinkedListQueue *queue) { + return (size(queue) == 0); + } + + /* 큐에 삽입 */ + void push(LinkedListQueue *queue, int num) { + // 꼬리 노드 위치에 node를 추가한다 + ListNode *node = newListNode(num); + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if (queue->front == NULL) { + queue->front = node; + queue->rear = node; + } + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + else { + queue->rear->next = node; + queue->rear = node; + } + queue->queSize++; + } + + /* 맨 앞 원소 접근 */ + int peek(LinkedListQueue *queue) { + assert(size(queue) && queue->front); + return queue->front->val; + } + + /* 큐에서 꺼내기 */ + int pop(LinkedListQueue *queue) { + int num = peek(queue); + ListNode *tmp = queue->front; + queue->front = queue->front->next; + free(tmp); + queue->queSize--; + return num; + } + + /* 큐 출력 */ + void printLinkedListQueue(LinkedListQueue *queue) { + int *arr = malloc(sizeof(int) * queue->queSize); + // 연결 리스트의 데이터를 배열에 복사한다 + int i; + ListNode *node; + for (i = 0, node = queue->front; i < queue->queSize; i++) { + arr[i] = node->val; + node = node->next; + } + printArray(arr, queue->queSize); + free(arr); + } + ``` + +=== "Kotlin" + + ```kotlin title="linkedlist_queue.kt" + /* 연결 리스트로 구현한 큐 */ + class LinkedListQueue( + // 머리 노드 front, 꼬리 노드 rear + private var front: ListNode? = null, + private var rear: ListNode? = null, + private var queSize: Int = 0 + ) { + + /* 큐의 길이 반환 */ + fun size(): Int { + return queSize + } + + /* 큐가 비어 있는지 판단 */ + fun isEmpty(): Boolean { + return size() == 0 + } + + /* 큐에 삽입 */ + fun push(num: Int) { + // 꼬리 노드 뒤에 num을 추가한다 + val node = ListNode(num) + // 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if (front == null) { + front = node + rear = node + // 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + } else { + rear?.next = node + rear = node + } + queSize++ + } + + /* 큐에서 꺼내기 */ + fun pop(): Int { + val num = peek() + // 머리 노드를 삭제한다 + front = front?.next + queSize-- + return num + } + + /* 맨 앞 원소 접근 */ + fun peek(): Int { + if (isEmpty()) throw IndexOutOfBoundsException() + return front!!._val + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + fun toArray(): IntArray { + var node = front + val res = IntArray(size()) + for (i in res.indices) { + res[i] = node!!._val + node = node.next + } + return res + } + } + ``` + +=== "Ruby" + + ```ruby title="linkedlist_queue.rb" + ### 연결 리스트로 구현한 큐 ### + class LinkedListQueue + ### 큐의 길이 반환 ### + attr_reader :size + + ### 생성자 ### + def initialize + @front = nil # 머리 노드 front + @rear = nil # 꼬리 노드 rear + @size = 0 + end + + ### 큐가 비어 있는지 판단 ### + def is_empty? + @front.nil? + end + + ### 큐에 삽입 ### + def push(num) + # 꼬리 노드 뒤에 num을 추가한다 + node = ListNode.new(num) + + # 큐가 비어 있으면 머리와 꼬리 노드가 모두 해당 노드를 가리키게 한다 + if @front.nil? + @front = node + @rear = node + # 큐가 비어 있지 않으면 해당 노드를 꼬리 노드 뒤에 추가한다 + else + @rear.next = node + @rear = node + end + + @size += 1 + end + + ### 큐에서 꺼내기 ### + def pop + num = peek + # 머리 노드를 삭제한다 + @front = @front.next + @size -= 1 + num + end + + ### 맨 앞 원소 접근 ### + def peek + raise IndexError, '큐가 비어 있다' if is_empty? + + @front.val + end + + ### 연결 리스트를 Array로 변환하여 반환 ### + def to_array + queue = [] + temp = @front + while temp + queue << temp.val + temp = temp.next + end + queue + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 2.   배열 기반 구현 + +배열에서 첫 원소를 삭제하는 시간 복잡도는 $O(n)$이므로, 큐에서 원소를 꺼내는 효율이 낮아진다. 그러나 다음과 같은 방법으로 이 문제를 피할 수 있다. + +변수 `front`로 큐의 맨 앞 원소 인덱스를 가리키고, 변수 `size`로 큐의 길이를 기록한다. `rear = front + size`로 정의하면 `rear`는 큐의 맨 뒤 원소 바로 다음 위치를 가리킨다. + +이 설계에서는 **배열에서 원소가 들어 있는 유효 구간이 `[front, rear - 1]`이며**, 각 연산의 구현 방법은 아래 그림(5-6)과 같다. + +- 삽입: 인덱스 `rear`에 입력 원소를 대입하고 `size`를 1 늘린다. +- 꺼내기: `front`를 1 늘리고 `size`를 1 줄이면 된다. + +두 연산 모두 한 번의 처리만 필요하므로 시간 복잡도는 $O(1)$이다. + +=== "<1>" + ![배열 기반 큐의 삽입과 꺼내기](queue.assets/array_queue_step1.png){ class="animation-figure" } + +=== "<2>" + ![배열 기반 큐에 원소 삽입](queue.assets/array_queue_step2_push.png){ class="animation-figure" } + +=== "<3>" + ![배열 기반 큐에서 원소 꺼내기](queue.assets/array_queue_step3_pop.png){ class="animation-figure" } + +

그림 5-6   배열 기반 큐의 삽입과 꺼내기

+ +여기에는 한 가지 문제가 있다. 삽입과 꺼내기를 반복하면 `front`와 `rear`가 계속 오른쪽으로 이동하다가 **배열의 끝에 도달하면 더 이상 움직일 수 없다**. 이를 해결하려면 배열을 앞뒤가 이어진 ‘원형 배열’로 볼 수 있다. + +원형 배열에서는 `front`나 `rear`가 배열의 끝을 넘으면 곧바로 처음으로 돌아와 순회를 계속하게 해야 한다. 이 주기적 규칙은 ‘나머지 연산’으로 구현할 수 있다. 코드는 다음과 같다. + +=== "Python" + + ```python title="array_queue.py" + class ArrayQueue: + """원형 배열로 구현한 큐""" + + def __init__(self, size: int): + """생성자""" + self._nums: list[int] = [0] * size # 큐의 원소를 저장하는 배열 + self._front: int = 0 # 맨 앞 원소를 가리키는 맨 앞 포인터 + self._size: int = 0 # 큐 길이 + + def capacity(self) -> int: + """큐의 용량 반환""" + return len(self._nums) + + def size(self) -> int: + """큐의 길이 반환""" + return self._size + + def is_empty(self) -> bool: + """큐가 비어 있는지 판단""" + return self._size == 0 + + def push(self, num: int): + """큐에 삽입""" + if self._size == self.capacity(): + raise IndexError("큐가 가득 찼다") + # 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + # 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + rear: int = (self._front + self._size) % self.capacity() + # num을 맨 뒤에 추가한다 + self._nums[rear] = num + self._size += 1 + + def pop(self) -> int: + """큐에서 꺼내기""" + num: int = self.peek() + # 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + self._front = (self._front + 1) % self.capacity() + self._size -= 1 + return num + + def peek(self) -> int: + """맨 앞 원소 접근""" + if self.is_empty(): + raise IndexError("큐가 비어 있다") + return self._nums[self._front] + + def to_list(self) -> list[int]: + """출력에 사용할 리스트 반환""" + res = [0] * self.size() + j: int = self._front + for i in range(self.size()): + res[i] = self._nums[(j % self.capacity())] + j += 1 + return res + ``` + +=== "C++" + + ```cpp title="array_queue.cpp" + /* 원형 배열로 구현한 큐 */ + class ArrayQueue { + private: + int *nums; // 큐의 원소를 저장하는 배열 + int front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + int queSize; // 큐 길이 + int queCapacity; // 큐 용량 + + public: + ArrayQueue(int capacity) { + // 배열 초기화 + nums = new int[capacity]; + queCapacity = capacity; + front = queSize = 0; + } + + ~ArrayQueue() { + delete[] nums; + } + + /* 큐의 용량 반환 */ + int capacity() { + return queCapacity; + } + + /* 큐의 길이 반환 */ + int size() { + return queSize; + } + + /* 큐가 비어 있는지 판단 */ + bool isEmpty() { + return size() == 0; + } + + /* 큐에 삽입 */ + void push(int num) { + if (queSize == queCapacity) { + cout << "큐가 가득 찼다" << endl; + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + int rear = (front + queSize) % queCapacity; + // num을 맨 뒤에 추가한다 + nums[rear] = num; + queSize++; + } + + /* 큐에서 꺼내기 */ + int pop() { + int num = peek(); + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + front = (front + 1) % queCapacity; + queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + int peek() { + if (isEmpty()) + throw out_of_range("큐가 비어 있다"); + return nums[front]; + } + + /* 배열을 Vector로 변환하여 반환 */ + vector toVector() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + vector arr(queSize); + for (int i = 0, j = front; i < queSize; i++, j++) { + arr[i] = nums[j % queCapacity]; + } + return arr; + } + }; + ``` + +=== "Java" + + ```java title="array_queue.java" + /* 원형 배열로 구현한 큐 */ + class ArrayQueue { + private int[] nums; // 큐의 원소를 저장하는 배열 + private int front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + private int queSize; // 큐 길이 + + public ArrayQueue(int capacity) { + nums = new int[capacity]; + front = queSize = 0; + } + + /* 큐의 용량 반환 */ + public int capacity() { + return nums.length; + } + + /* 큐의 길이 반환 */ + public int size() { + return queSize; + } + + /* 큐가 비어 있는지 판단 */ + public boolean isEmpty() { + return queSize == 0; + } + + /* 큐에 삽입 */ + public void push(int num) { + if (queSize == capacity()) { + System.out.println("큐가 가득 찼다"); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + int rear = (front + queSize) % capacity(); + // num을 맨 뒤에 추가한다 + nums[rear] = num; + queSize++; + } + + /* 큐에서 꺼내기 */ + public int pop() { + int num = peek(); + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + front = (front + 1) % capacity(); + queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + public int peek() { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + return nums[front]; + } + + /* 배열 반환 */ + public int[] toArray() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + int[] res = new int[queSize]; + for (int i = 0, j = front; i < queSize; i++, j++) { + res[i] = nums[j % capacity()]; + } + return res; + } + } + ``` + +=== "C#" + + ```csharp title="array_queue.cs" + /* 원형 배열로 구현한 큐 */ + class ArrayQueue { + int[] nums; // 큐의 원소를 저장하는 배열 + int front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + int queSize; // 큐 길이 + + public ArrayQueue(int capacity) { + nums = new int[capacity]; + front = queSize = 0; + } + + /* 큐의 용량 반환 */ + int Capacity() { + return nums.Length; + } + + /* 큐의 길이 반환 */ + public int Size() { + return queSize; + } + + /* 큐가 비어 있는지 판단 */ + public bool IsEmpty() { + return queSize == 0; + } + + /* 큐에 삽입 */ + public void Push(int num) { + if (queSize == Capacity()) { + Console.WriteLine("큐가 가득 찼다"); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + int rear = (front + queSize) % Capacity(); + // num을 맨 뒤에 추가한다 + nums[rear] = num; + queSize++; + } + + /* 큐에서 꺼내기 */ + public int Pop() { + int num = Peek(); + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + front = (front + 1) % Capacity(); + queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + public int Peek() { + if (IsEmpty()) + throw new Exception(); + return nums[front]; + } + + /* 배열 반환 */ + public int[] ToArray() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + int[] res = new int[queSize]; + for (int i = 0, j = front; i < queSize; i++, j++) { + res[i] = nums[j % this.Capacity()]; + } + return res; + } + } + ``` + +=== "Go" + + ```go title="array_queue.go" + /* 원형 배열로 구현한 큐 */ + type arrayQueue struct { + nums []int // 큐의 원소를 저장하는 배열 + front int // 맨 앞 원소를 가리키는 맨 앞 포인터 + queSize int // 큐 길이 + queCapacity int // 큐 용량(저장할 수 있는 최대 원소 개수) + } + + /* 큐 초기화 */ + func newArrayQueue(queCapacity int) *arrayQueue { + return &arrayQueue{ + nums: make([]int, queCapacity), + queCapacity: queCapacity, + front: 0, + queSize: 0, + } + } + + /* 큐의 길이 반환 */ + func (q *arrayQueue) size() int { + return q.queSize + } + + /* 큐가 비어 있는지 판단 */ + func (q *arrayQueue) isEmpty() bool { + return q.queSize == 0 + } + + /* 큐에 삽입 */ + func (q *arrayQueue) push(num int) { + // rear == queCapacity이면 큐가 가득 찼음을 나타낸다 + if q.queSize == q.queCapacity { + return + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + rear := (q.front + q.queSize) % q.queCapacity + // num을 맨 뒤에 추가한다 + q.nums[rear] = num + q.queSize++ + } + + /* 큐에서 꺼내기 */ + func (q *arrayQueue) pop() any { + num := q.peek() + if num == nil { + return nil + } + + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + q.front = (q.front + 1) % q.queCapacity + q.queSize-- + return num + } + + /* 맨 앞 원소 접근 */ + func (q *arrayQueue) peek() any { + if q.isEmpty() { + return nil + } + return q.nums[q.front] + } + + /* 출력에 사용할 Slice 반환 */ + func (q *arrayQueue) toSlice() []int { + rear := (q.front + q.queSize) + if rear >= q.queCapacity { + rear %= q.queCapacity + return append(q.nums[q.front:], q.nums[:rear]...) + } + return q.nums[q.front:rear] + } + ``` + +=== "Swift" + + ```swift title="array_queue.swift" + /* 원형 배열로 구현한 큐 */ + class ArrayQueue { + private var nums: [Int] // 큐의 원소를 저장하는 배열 + private var front: Int // 맨 앞 원소를 가리키는 맨 앞 포인터 + private var _size: Int // 큐 길이 + + init(capacity: Int) { + // 배열 초기화 + nums = Array(repeating: 0, count: capacity) + front = 0 + _size = 0 + } + + /* 큐의 용량 반환 */ + func capacity() -> Int { + nums.count + } + + /* 큐의 길이 반환 */ + func size() -> Int { + _size + } + + /* 큐가 비어 있는지 판단 */ + func isEmpty() -> Bool { + size() == 0 + } + + /* 큐에 삽입 */ + func push(num: Int) { + if size() == capacity() { + print("큐가 가득 찼다") + return + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + let rear = (front + size()) % capacity() + // num을 맨 뒤에 추가한다 + nums[rear] = num + _size += 1 + } + + /* 큐에서 꺼내기 */ + @discardableResult + func pop() -> Int { + let num = peek() + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + front = (front + 1) % capacity() + _size -= 1 + return num + } + + /* 맨 앞 원소 접근 */ + func peek() -> Int { + if isEmpty() { + fatalError("큐가 비어 있다") + } + return nums[front] + } + + /* 배열 반환 */ + func toArray() -> [Int] { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + (front ..< front + size()).map { nums[$0 % capacity()] } + } + } + ``` + +=== "JS" + + ```javascript title="array_queue.js" + /* 원형 배열로 구현한 큐 */ + class ArrayQueue { + #nums; // 큐의 원소를 저장하는 배열 + #front = 0; // 맨 앞 원소를 가리키는 맨 앞 포인터 + #queSize = 0; // 큐 길이 + + constructor(capacity) { + this.#nums = new Array(capacity); + } + + /* 큐의 용량 반환 */ + get capacity() { + return this.#nums.length; + } + + /* 큐의 길이 반환 */ + get size() { + return this.#queSize; + } + + /* 큐가 비어 있는지 판단 */ + isEmpty() { + return this.#queSize === 0; + } + + /* 큐에 삽입 */ + push(num) { + if (this.size === this.capacity) { + console.log('큐가 가득 찼다'); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + const rear = (this.#front + this.size) % this.capacity; + // num을 맨 뒤에 추가한다 + this.#nums[rear] = num; + this.#queSize++; + } + + /* 큐에서 꺼내기 */ + pop() { + const num = this.peek(); + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + this.#front = (this.#front + 1) % this.capacity; + this.#queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + peek() { + if (this.isEmpty()) throw new Error('큐가 비어 있다'); + return this.#nums[this.#front]; + } + + /* Array를 반환한다 */ + toArray() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + const arr = new Array(this.size); + for (let i = 0, j = this.#front; i < this.size; i++, j++) { + arr[i] = this.#nums[j % this.capacity]; + } + return arr; + } + } + ``` + +=== "TS" + + ```typescript title="array_queue.ts" + /* 원형 배열로 구현한 큐 */ + class ArrayQueue { + private nums: number[]; // 큐의 원소를 저장하는 배열 + private front: number; // 맨 앞 원소를 가리키는 맨 앞 포인터 + private queSize: number; // 큐 길이 + + constructor(capacity: number) { + this.nums = new Array(capacity); + this.front = this.queSize = 0; + } + + /* 큐의 용량 반환 */ + get capacity(): number { + return this.nums.length; + } + + /* 큐의 길이 반환 */ + get size(): number { + return this.queSize; + } + + /* 큐가 비어 있는지 판단 */ + isEmpty(): boolean { + return this.queSize === 0; + } + + /* 큐에 삽입 */ + push(num: number): void { + if (this.size === this.capacity) { + console.log('큐가 가득 찼다'); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + const rear = (this.front + this.queSize) % this.capacity; + // num을 맨 뒤에 추가한다 + this.nums[rear] = num; + this.queSize++; + } + + /* 큐에서 꺼내기 */ + pop(): number { + const num = this.peek(); + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + this.front = (this.front + 1) % this.capacity; + this.queSize--; + return num; + } + + /* 맨 앞 원소 접근 */ + peek(): number { + if (this.isEmpty()) throw new Error('큐가 비어 있다'); + return this.nums[this.front]; + } + + /* Array를 반환한다 */ + toArray(): number[] { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + const arr = new Array(this.size); + for (let i = 0, j = this.front; i < this.size; i++, j++) { + arr[i] = this.nums[j % this.capacity]; + } + return arr; + } + } + ``` + +=== "Dart" + + ```dart title="array_queue.dart" + /* 원형 배열로 구현한 큐 */ + class ArrayQueue { + late List _nums; // 큐의 원소를 저장하는 배열 + late int _front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + late int _queSize; // 큐 길이 + + ArrayQueue(int capacity) { + _nums = List.filled(capacity, 0); + _front = _queSize = 0; + } + + /* 큐의 용량 반환 */ + int capaCity() { + return _nums.length; + } + + /* 큐의 길이 반환 */ + int size() { + return _queSize; + } + + /* 큐가 비어 있는지 판단 */ + bool isEmpty() { + return _queSize == 0; + } + + /* 큐에 삽입 */ + void push(int _num) { + if (_queSize == capaCity()) { + throw Exception("큐가 가득 찼다"); + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + int rear = (_front + _queSize) % capaCity(); + // _num을 맨 뒤에 추가한다 + _nums[rear] = _num; + _queSize++; + } + + /* 큐에서 꺼내기 */ + int pop() { + int _num = peek(); + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + _front = (_front + 1) % capaCity(); + _queSize--; + return _num; + } + + /* 맨 앞 원소 접근 */ + int peek() { + if (isEmpty()) { + throw Exception("큐가 비어 있다"); + } + return _nums[_front]; + } + + /* Array를 반환한다 */ + List toArray() { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + final List res = List.filled(_queSize, 0); + for (int i = 0, j = _front; i < _queSize; i++, j++) { + res[i] = _nums[j % capaCity()]; + } + return res; + } + } + ``` + +=== "Rust" + + ```rust title="array_queue.rs" + /* 원형 배열로 구현한 큐 */ + struct ArrayQueue { + nums: Vec, // 큐의 원소를 저장하는 배열 + front: i32, // 맨 앞 원소를 가리키는 맨 앞 포인터 + que_size: i32, // 큐 길이 + que_capacity: i32, // 큐 용량 + } + + impl ArrayQueue { + /* 생성자 */ + fn new(capacity: i32) -> ArrayQueue { + ArrayQueue { + nums: vec![T::default(); capacity as usize], + front: 0, + que_size: 0, + que_capacity: capacity, + } + } + + /* 큐의 용량 반환 */ + fn capacity(&self) -> i32 { + self.que_capacity + } + + /* 큐의 길이 반환 */ + fn size(&self) -> i32 { + self.que_size + } + + /* 큐가 비어 있는지 판단 */ + fn is_empty(&self) -> bool { + self.que_size == 0 + } + + /* 큐에 삽입 */ + fn push(&mut self, num: T) { + if self.que_size == self.capacity() { + println!("큐가 가득 찼다"); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + let rear = (self.front + self.que_size) % self.que_capacity; + // num을 맨 뒤에 추가한다 + self.nums[rear as usize] = num; + self.que_size += 1; + } + + /* 큐에서 꺼내기 */ + fn pop(&mut self) -> T { + let num = self.peek(); + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + self.front = (self.front + 1) % self.que_capacity; + self.que_size -= 1; + num + } + + /* 맨 앞 원소 접근 */ + fn peek(&self) -> T { + if self.is_empty() { + panic!("인덱스가 범위를 벗어났다"); + } + self.nums[self.front as usize] + } + + /* 배열 반환 */ + fn to_vector(&self) -> Vec { + let cap = self.que_capacity; + let mut j = self.front; + let mut arr = vec![T::default(); cap as usize]; + for i in 0..self.que_size { + arr[i as usize] = self.nums[(j % cap) as usize]; + j += 1; + } + arr + } + } + ``` + +=== "C" + + ```c title="array_queue.c" + /* 원형 배열로 구현한 큐 */ + typedef struct { + int *nums; // 큐의 원소를 저장하는 배열 + int front; // 맨 앞 원소를 가리키는 맨 앞 포인터 + int queSize; // 현재 큐의 원소 개수 + int queCapacity; // 큐 용량 + } ArrayQueue; + + /* 생성자 */ + ArrayQueue *newArrayQueue(int capacity) { + ArrayQueue *queue = (ArrayQueue *)malloc(sizeof(ArrayQueue)); + // 배열 초기화 + queue->queCapacity = capacity; + queue->nums = (int *)malloc(sizeof(int) * queue->queCapacity); + queue->front = queue->queSize = 0; + return queue; + } + + /* 소멸자 */ + void delArrayQueue(ArrayQueue *queue) { + free(queue->nums); + free(queue); + } + + /* 큐의 용량 반환 */ + int capacity(ArrayQueue *queue) { + return queue->queCapacity; + } + + /* 큐의 길이 반환 */ + int size(ArrayQueue *queue) { + return queue->queSize; + } + + /* 큐가 비어 있는지 판단 */ + bool empty(ArrayQueue *queue) { + return queue->queSize == 0; + } + + /* 맨 앞 원소 접근 */ + int peek(ArrayQueue *queue) { + assert(size(queue) != 0); + return queue->nums[queue->front]; + } + + /* 큐에 삽입 */ + void push(ArrayQueue *queue, int num) { + if (size(queue) == capacity(queue)) { + printf("큐가 가득 찼다\r\n"); + return; + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + int rear = (queue->front + queue->queSize) % queue->queCapacity; + // num을 맨 뒤에 추가한다 + queue->nums[rear] = num; + queue->queSize++; + } + + /* 큐에서 꺼내기 */ + int pop(ArrayQueue *queue) { + int num = peek(queue); + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + queue->front = (queue->front + 1) % queue->queCapacity; + queue->queSize--; + return num; + } + + /* 출력에 사용할 배열 반환 */ + int *toArray(ArrayQueue *queue, int *queSize) { + *queSize = queue->queSize; + int *res = (int *)calloc(queue->queSize, sizeof(int)); + int j = queue->front; + for (int i = 0; i < queue->queSize; i++) { + res[i] = queue->nums[j % queue->queCapacity]; + j++; + } + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="array_queue.kt" + /* 원형 배열로 구현한 큐 */ + class ArrayQueue(capacity: Int) { + private val nums: IntArray = IntArray(capacity) // 큐의 원소를 저장하는 배열 + private var front: Int = 0 // 맨 앞 원소를 가리키는 맨 앞 포인터 + private var queSize: Int = 0 // 큐 길이 + + /* 큐의 용량 반환 */ + fun capacity(): Int { + return nums.size + } + + /* 큐의 길이 반환 */ + fun size(): Int { + return queSize + } + + /* 큐가 비어 있는지 판단 */ + fun isEmpty(): Boolean { + return queSize == 0 + } + + /* 큐에 삽입 */ + fun push(num: Int) { + if (queSize == capacity()) { + println("큐가 가득 찼다") + return + } + // 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + // 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + val rear = (front + queSize) % capacity() + // num을 맨 뒤에 추가한다 + nums[rear] = num + queSize++ + } + + /* 큐에서 꺼내기 */ + fun pop(): Int { + val num = peek() + // 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + front = (front + 1) % capacity() + queSize-- + return num + } + + /* 맨 앞 원소 접근 */ + fun peek(): Int { + if (isEmpty()) throw IndexOutOfBoundsException() + return nums[front] + } + + /* 배열 반환 */ + fun toArray(): IntArray { + // 유효한 길이 범위 안의 리스트 원소만 변환한다 + val res = IntArray(queSize) + var i = 0 + var j = front + while (i < queSize) { + res[i] = nums[j % capacity()] + i++ + j++ + } + return res + } + } + ``` + +=== "Ruby" + + ```ruby title="array_queue.rb" + ### 원형 배열로 구현한 큐 ### + class ArrayQueue + ### 큐의 길이 반환 ### + attr_reader :size + + ### 생성자 ### + def initialize(size) + @nums = Array.new(size, 0) # 큐의 원소를 저장하는 배열 + @front = 0 # 맨 앞 원소를 가리키는 맨 앞 포인터 + @size = 0 # 큐 길이 + end + + ### 큐의 용량 반환 ### + def capacity + @nums.length + end + + ### 큐가 비어 있는지 판단 ### + def is_empty? + size.zero? + end + + ### 큐에 삽입 ### + def push(num) + raise IndexError, '큐가 가득 찼다' if size == capacity + + # 맨 뒤 인덱스 + 1을 가리키는 맨 뒤 포인터를 계산한다 + # 나머지 연산을 통해 rear가 배열의 끝을 넘어가면 처음으로 돌아오게 한다 + rear = (@front + size) % capacity + # num을 맨 뒤에 추가한다 + @nums[rear] = num + @size += 1 + end + + ### 큐에서 꺼내기 ### + def pop + num = peek + # 맨 앞 포인터를 뒤로 한 칸 이동하고, 끝을 넘어가면 배열의 처음으로 돌아온다 + @front = (@front + 1) % capacity + @size -= 1 + num + end + + ### 맨 앞 원소 접근 ### + def peek + raise IndexError, '큐가 비어 있다' if is_empty? + + @nums[@front] + end + + ### 출력에 사용할 리스트 반환 ### + def to_array + res = Array.new(size, 0) + j = @front + + for i in 0...size + res[i] = @nums[j % capacity] + j += 1 + end + + res + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +위 큐 구현에는 여전히 길이를 바꿀 수 없다는 한계가 있다. 하지만 배열을 동적 배열로 바꾸어 용량 확장 방식을 도입하면 쉽게 해결할 수 있다. 관심 있는 독자는 직접 구현해 보자. + +두 구현의 비교 결과는 스택과 같으므로 여기서는 반복해서 설명하지 않는다. + +## 5.2.3   큐의 대표적인 활용 + +- **타오바오 주문**. 구매자가 주문하면 큐에 들어가고, 시스템은 순서대로 처리한다. 광군제 기간에는 짧은 시간에 엄청난 주문이 몰리므로 높은 동시성을 처리하는 일이 엔지니어들의 핵심 과제가 된다. +- **여러 종류의 대기 작업**. 프린터 작업 대기열, 식당 음식 제공 대기열 등 ‘먼저 온 순서’를 지켜야 하는 상황에서 큐는 처리 순서를 효과적으로 유지한다. diff --git a/ko/docs/chapter_stack_and_queue/stack.md b/ko/docs/chapter_stack_and_queue/stack.md new file mode 100644 index 000000000..ad52a1af6 --- /dev/null +++ b/ko/docs/chapter_stack_and_queue/stack.md @@ -0,0 +1,1838 @@ +--- +comments: true +--- + +# 5.1   스택 + +스택(stack)은 후입선출 원리를 따르는 선형 자료 구조다. + +스택은 탁자 위에 쌓인 접시에 비유할 수 있다. 한 번에 접시 하나만 옮길 수 있다면 맨 아래 접시를 꺼내려면 위의 접시들을 차례로 치워야 한다. 접시를 정수, 문자, 객체 등 여러 자료형의 원소로 바꾸면 스택이라는 자료 구조가 된다. + +아래 그림(5-1)과 같이 원소가 쌓인 맨 위를 ‘스택의 꼭대기’, 맨 아래를 ‘스택의 바닥’이라고 한다. 맨 위에 원소를 추가하는 연산을 ‘스택에 삽입하기’, 맨 위 원소를 삭제하는 연산을 ‘스택에서 꺼내기’라고 한다. + +![스택의 후입선출 규칙](stack.assets/stack_operations.png){ class="animation-figure" } + +

그림 5-1   스택의 후입선출 규칙

+ +## 5.1.1   스택의 기본 연산 + +스택의 기본 연산은 아래 표(5-1)와 같다. 구체적인 메서드 이름은 프로그래밍 언어에 따라 다르며, 여기서는 흔히 쓰이는 `push()`, `pop()`, `peek()`를 예로 든다. + +

표 5-1   스택의 연산 효율

+ +
+ +| 메서드 | 설명 | 시간 복잡도 | +| -------- | ---------------------- | ---------- | +| `push()` | 원소를 스택에 삽입(맨 위에 추가) | $O(1)$ | +| `pop()` | 스택 맨 위 원소를 꺼냄 | $O(1)$ | +| `peek()` | 스택 맨 위 원소에 접근 | $O(1)$ | + +
+ +보통 프로그래밍 언어에 내장된 스택 클래스를 바로 사용할 수 있다. 전용 스택 클래스가 없는 언어에서는 ‘배열’이나 ‘연결 리스트’를 스택으로 사용하면서, 프로그램 논리상 스택과 무관한 연산은 사용하지 않으면 된다. + +=== "Python" + + ```python title="stack.py" + # 스택 초기화 + # Python에는 내장 스택 클래스가 없으므로 list를 스택으로 사용할 수 있다 + stack: list[int] = [] + + # 스택에 원소 삽입 + stack.append(1) + stack.append(3) + stack.append(2) + stack.append(5) + stack.append(4) + + # 스택 맨 위 원소에 접근 + peek: int = stack[-1] + + # 스택에서 원소 꺼내기 + pop: int = stack.pop() + + # 스택 길이 구하기 + size: int = len(stack) + + # 비어 있는지 확인 + is_empty: bool = len(stack) == 0 + ``` + +=== "C++" + + ```cpp title="stack.cpp" + /* 스택 초기화 */ + stack stack; + + /* 스택에 원소 삽입 */ + stack.push(1); + stack.push(3); + stack.push(2); + stack.push(5); + stack.push(4); + + /* 스택 맨 위 원소에 접근 */ + int top = stack.top(); + + /* 스택에서 원소 꺼내기 */ + stack.pop(); // 반환값 없음 + + /* 스택 길이 구하기 */ + int size = stack.size(); + + /* 비어 있는지 확인 */ + bool empty = stack.empty(); + ``` + +=== "Java" + + ```java title="stack.java" + /* 스택 초기화 */ + Stack stack = new Stack<>(); + + /* 스택에 원소 삽입 */ + stack.push(1); + stack.push(3); + stack.push(2); + stack.push(5); + stack.push(4); + + /* 스택 맨 위 원소에 접근 */ + int peek = stack.peek(); + + /* 스택에서 원소 꺼내기 */ + int pop = stack.pop(); + + /* 스택 길이 구하기 */ + int size = stack.size(); + + /* 비어 있는지 확인 */ + boolean isEmpty = stack.isEmpty(); + ``` + +=== "C#" + + ```csharp title="stack.cs" + /* 스택 초기화 */ + Stack stack = new(); + + /* 스택에 원소 삽입 */ + stack.Push(1); + stack.Push(3); + stack.Push(2); + stack.Push(5); + stack.Push(4); + + /* 스택 맨 위 원소에 접근 */ + int peek = stack.Peek(); + + /* 스택에서 원소 꺼내기 */ + int pop = stack.Pop(); + + /* 스택 길이 구하기 */ + int size = stack.Count; + + /* 비어 있는지 확인 */ + bool isEmpty = stack.Count == 0; + ``` + +=== "Go" + + ```go title="stack_test.go" + /* 스택 초기화 */ + // Go에서는 Slice를 스택으로 사용하는 것을 권한다 + var stack []int + + /* 스택에 원소 삽입 */ + stack = append(stack, 1) + stack = append(stack, 3) + stack = append(stack, 2) + stack = append(stack, 5) + stack = append(stack, 4) + + /* 스택 맨 위 원소에 접근 */ + peek := stack[len(stack)-1] + + /* 스택에서 원소 꺼내기 */ + pop := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + /* 스택 길이 구하기 */ + size := len(stack) + + /* 비어 있는지 확인 */ + isEmpty := len(stack) == 0 + ``` + +=== "Swift" + + ```swift title="stack.swift" + /* 스택 초기화 */ + // Swift에는 내장 스택 클래스가 없으므로 Array를 스택으로 사용할 수 있다 + var stack: [Int] = [] + + /* 스택에 원소 삽입 */ + stack.append(1) + stack.append(3) + stack.append(2) + stack.append(5) + stack.append(4) + + /* 스택 맨 위 원소에 접근 */ + let peek = stack.last! + + /* 스택에서 원소 꺼내기 */ + let pop = stack.removeLast() + + /* 스택 길이 구하기 */ + let size = stack.count + + /* 비어 있는지 확인 */ + let isEmpty = stack.isEmpty + ``` + +=== "JS" + + ```javascript title="stack.js" + /* 스택 초기화 */ + // JavaScript에는 내장 스택 클래스가 없으므로 Array를 스택으로 사용할 수 있다 + const stack = []; + + /* 스택에 원소 삽입 */ + stack.push(1); + stack.push(3); + stack.push(2); + stack.push(5); + stack.push(4); + + /* 스택 맨 위 원소에 접근 */ + const peek = stack[stack.length-1]; + + /* 스택에서 원소 꺼내기 */ + const pop = stack.pop(); + + /* 스택 길이 구하기 */ + const size = stack.length; + + /* 비어 있는지 확인 */ + const is_empty = stack.length === 0; + ``` + +=== "TS" + + ```typescript title="stack.ts" + /* 스택 초기화 */ + // TypeScript에는 내장 스택 클래스가 없으므로 Array를 스택으로 사용할 수 있다 + const stack: number[] = []; + + /* 스택에 원소 삽입 */ + stack.push(1); + stack.push(3); + stack.push(2); + stack.push(5); + stack.push(4); + + /* 스택 맨 위 원소에 접근 */ + const peek = stack[stack.length - 1]; + + /* 스택에서 원소 꺼내기 */ + const pop = stack.pop(); + + /* 스택 길이 구하기 */ + const size = stack.length; + + /* 비어 있는지 확인 */ + const is_empty = stack.length === 0; + ``` + +=== "Dart" + + ```dart title="stack.dart" + /* 스택 초기화 */ + // Dart에는 내장 스택 클래스가 없으므로 List를 스택으로 사용할 수 있다 + List stack = []; + + /* 스택에 원소 삽입 */ + stack.add(1); + stack.add(3); + stack.add(2); + stack.add(5); + stack.add(4); + + /* 스택 맨 위 원소에 접근 */ + int peek = stack.last; + + /* 스택에서 원소 꺼내기 */ + int pop = stack.removeLast(); + + /* 스택 길이 구하기 */ + int size = stack.length; + + /* 비어 있는지 확인 */ + bool isEmpty = stack.isEmpty; + ``` + +=== "Rust" + + ```rust title="stack.rs" + /* 스택 초기화 */ + // Vec를 스택으로 사용 + let mut stack: Vec = Vec::new(); + + /* 스택에 원소 삽입 */ + stack.push(1); + stack.push(3); + stack.push(2); + stack.push(5); + stack.push(4); + + /* 스택 맨 위 원소에 접근 */ + let top = stack.last().unwrap(); + + /* 스택에서 원소 꺼내기 */ + let pop = stack.pop().unwrap(); + + /* 스택 길이 구하기 */ + let size = stack.len(); + + /* 비어 있는지 확인 */ + let is_empty = stack.is_empty(); + ``` + +=== "C" + + ```c title="stack.c" + // C는 내장 스택을 제공하지 않는다 + ``` + +=== "Kotlin" + + ```kotlin title="stack.kt" + /* 스택 초기화 */ + val stack = Stack() + + /* 스택에 원소 삽입 */ + stack.push(1) + stack.push(3) + stack.push(2) + stack.push(5) + stack.push(4) + + /* 스택 맨 위 원소에 접근 */ + val peek = stack.peek() + + /* 스택에서 원소 꺼내기 */ + val pop = stack.pop() + + /* 스택 길이 구하기 */ + val size = stack.size + + /* 비어 있는지 확인 */ + val isEmpty = stack.isEmpty() + ``` + +=== "Ruby" + + ```ruby title="stack.rb" + # 스택 초기화 + # Ruby에는 내장 스택 클래스가 없으므로 Array를 스택으로 사용할 수 있다 + stack = [] + + # 스택에 원소 삽입 + stack << 1 + stack << 3 + stack << 2 + stack << 5 + stack << 4 + + # 스택 맨 위 원소에 접근 + peek = stack.last + + # 스택에서 원소 꺼내기 + pop = stack.pop + + # 스택 길이 구하기 + size = stack.length + + # 비어 있는지 확인 + is_empty = stack.empty? + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 5.1.2   스택 구현 + +스택의 작동 원리를 깊이 이해하기 위해 스택 클래스를 직접 구현해 보자. + +스택은 후입선출 원리를 따르므로 맨 위에서만 원소를 추가하거나 삭제할 수 있다. 반면 배열과 연결 리스트는 어느 위치에서든 추가와 삭제가 가능하므로, **스택은 제약을 가한 배열이나 연결 리스트로 볼 수 있다**. 즉, 일부 무관한 연산을 ‘가려서’ 외부에 보이는 논리가 스택의 특성에 맞도록 만들 수 있다. + +### 1.   연결 리스트 기반 구현 + +연결 리스트로 스택을 구현할 때는 머리 노드를 스택의 꼭대기로, 꼬리 노드를 바닥으로 볼 수 있다. + +아래 그림(5-2)과 같이 스택에 삽입할 때는 연결 리스트의 맨 앞에 원소를 넣으면 된다. 이 방식을 ‘머리 삽입’이라고 한다. 스택에서 꺼낼 때는 머리 노드를 삭제하면 된다. + +=== "<1>" + ![연결 리스트 기반 스택의 삽입과 꺼내기](stack.assets/linkedlist_stack_step1.png){ class="animation-figure" } + +=== "<2>" + ![연결 리스트 기반 스택에 원소 삽입](stack.assets/linkedlist_stack_step2_push.png){ class="animation-figure" } + +=== "<3>" + ![연결 리스트 기반 스택에서 원소 꺼내기](stack.assets/linkedlist_stack_step3_pop.png){ class="animation-figure" } + +

그림 5-2   연결 리스트 기반 스택의 삽입과 꺼내기

+ +연결 리스트로 스택을 구현한 예제 코드는 다음과 같다. + +=== "Python" + + ```python title="linkedlist_stack.py" + class LinkedListStack: + """연결 리스트로 구현한 스택""" + + def __init__(self): + """생성자""" + self._peek: ListNode | None = None + self._size: int = 0 + + def size(self) -> int: + """스택의 길이 반환""" + return self._size + + def is_empty(self) -> bool: + """스택이 비어 있는지 판단""" + return self._size == 0 + + def push(self, val: int): + """스택에 삽입""" + node = ListNode(val) + node.next = self._peek + self._peek = node + self._size += 1 + + def pop(self) -> int: + """스택에서 꺼내기""" + num = self.peek() + self._peek = self._peek.next + self._size -= 1 + return num + + def peek(self) -> int: + """스택 맨 위 원소 접근""" + if self.is_empty(): + raise IndexError("스택이 비어 있다") + return self._peek.val + + def to_list(self) -> list[int]: + """출력에 사용할 리스트로 변환""" + arr = [] + node = self._peek + while node: + arr.append(node.val) + node = node.next + arr.reverse() + return arr + ``` + +=== "C++" + + ```cpp title="linkedlist_stack.cpp" + /* 연결 리스트로 구현한 스택 */ + class LinkedListStack { + private: + ListNode *stackTop; // 머리 노드를 스택의 맨 위로 사용한다 + int stkSize; // 스택의 길이 + + public: + LinkedListStack() { + stackTop = nullptr; + stkSize = 0; + } + + ~LinkedListStack() { + // 연결 리스트를 순회하며 노드를 삭제하여 메모리를 해제한다 + freeMemoryLinkedList(stackTop); + } + + /* 스택의 길이 반환 */ + int size() { + return stkSize; + } + + /* 스택이 비어 있는지 판단 */ + bool isEmpty() { + return size() == 0; + } + + /* 스택에 삽입 */ + void push(int num) { + ListNode *node = new ListNode(num); + node->next = stackTop; + stackTop = node; + stkSize++; + } + + /* 스택에서 꺼내기 */ + int pop() { + int num = top(); + ListNode *tmp = stackTop; + stackTop = stackTop->next; + // 메모리 해제 + delete tmp; + stkSize--; + return num; + } + + /* 스택 맨 위 원소 접근 */ + int top() { + if (isEmpty()) + throw out_of_range("스택이 비어 있다"); + return stackTop->val; + } + + /* List를 Array로 변환하여 반환 */ + vector toVector() { + ListNode *node = stackTop; + vector res(size()); + for (int i = res.size() - 1; i >= 0; i--) { + res[i] = node->val; + node = node->next; + } + return res; + } + }; + ``` + +=== "Java" + + ```java title="linkedlist_stack.java" + /* 연결 리스트로 구현한 스택 */ + class LinkedListStack { + private ListNode stackPeek; // 머리 노드를 스택의 맨 위로 사용한다 + private int stkSize = 0; // 스택의 길이 + + public LinkedListStack() { + stackPeek = null; + } + + /* 스택의 길이 반환 */ + public int size() { + return stkSize; + } + + /* 스택이 비어 있는지 판단 */ + public boolean isEmpty() { + return size() == 0; + } + + /* 스택에 삽입 */ + public void push(int num) { + ListNode node = new ListNode(num); + node.next = stackPeek; + stackPeek = node; + stkSize++; + } + + /* 스택에서 꺼내기 */ + public int pop() { + int num = peek(); + stackPeek = stackPeek.next; + stkSize--; + return num; + } + + /* 스택 맨 위 원소 접근 */ + public int peek() { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + return stackPeek.val; + } + + /* List를 Array로 변환하여 반환 */ + public int[] toArray() { + ListNode node = stackPeek; + int[] res = new int[size()]; + for (int i = res.length - 1; i >= 0; i--) { + res[i] = node.val; + node = node.next; + } + return res; + } + } + ``` + +=== "C#" + + ```csharp title="linkedlist_stack.cs" + /* 연결 리스트로 구현한 스택 */ + class LinkedListStack { + ListNode? stackPeek; // 머리 노드를 스택의 맨 위로 사용한다 + int stkSize = 0; // 스택의 길이 + + public LinkedListStack() { + stackPeek = null; + } + + /* 스택의 길이 반환 */ + public int Size() { + return stkSize; + } + + /* 스택이 비어 있는지 판단 */ + public bool IsEmpty() { + return Size() == 0; + } + + /* 스택에 삽입 */ + public void Push(int num) { + ListNode node = new(num) { + next = stackPeek + }; + stackPeek = node; + stkSize++; + } + + /* 스택에서 꺼내기 */ + public int Pop() { + int num = Peek(); + stackPeek = stackPeek!.next; + stkSize--; + return num; + } + + /* 스택 맨 위 원소 접근 */ + public int Peek() { + if (IsEmpty()) + throw new Exception(); + return stackPeek!.val; + } + + /* List를 Array로 변환하여 반환 */ + public int[] ToArray() { + if (stackPeek == null) + return []; + + ListNode? node = stackPeek; + int[] res = new int[Size()]; + for (int i = res.Length - 1; i >= 0; i--) { + res[i] = node!.val; + node = node.next; + } + return res; + } + } + ``` + +=== "Go" + + ```go title="linkedlist_stack.go" + /* 연결 리스트로 구현한 스택 */ + type linkedListStack struct { + // 내장 패키지 list로 스택을 구현한다 + data *list.List + } + + /* 스택 초기화 */ + func newLinkedListStack() *linkedListStack { + return &linkedListStack{ + data: list.New(), + } + } + + /* 스택에 삽입 */ + func (s *linkedListStack) push(value int) { + s.data.PushBack(value) + } + + /* 스택에서 꺼내기 */ + func (s *linkedListStack) pop() any { + if s.isEmpty() { + return nil + } + e := s.data.Back() + s.data.Remove(e) + return e.Value + } + + /* 스택 맨 위 원소 접근 */ + func (s *linkedListStack) peek() any { + if s.isEmpty() { + return nil + } + e := s.data.Back() + return e.Value + } + + /* 스택의 길이 반환 */ + func (s *linkedListStack) size() int { + return s.data.Len() + } + + /* 스택이 비어 있는지 판단 */ + func (s *linkedListStack) isEmpty() bool { + return s.data.Len() == 0 + } + + /* 출력에 사용할 List 반환 */ + func (s *linkedListStack) toList() *list.List { + return s.data + } + ``` + +=== "Swift" + + ```swift title="linkedlist_stack.swift" + /* 연결 리스트로 구현한 스택 */ + class LinkedListStack { + private var _peek: ListNode? // 머리 노드를 스택의 맨 위로 사용한다 + private var _size: Int // 스택의 길이 + + init() { + _size = 0 + } + + /* 스택의 길이 반환 */ + func size() -> Int { + _size + } + + /* 스택이 비어 있는지 판단 */ + func isEmpty() -> Bool { + size() == 0 + } + + /* 스택에 삽입 */ + func push(num: Int) { + let node = ListNode(x: num) + node.next = _peek + _peek = node + _size += 1 + } + + /* 스택에서 꺼내기 */ + @discardableResult + func pop() -> Int { + let num = peek() + _peek = _peek?.next + _size -= 1 + return num + } + + /* 스택 맨 위 원소 접근 */ + func peek() -> Int { + if isEmpty() { + fatalError("스택이 비어 있다") + } + return _peek!.val + } + + /* List를 Array로 변환하여 반환 */ + func toArray() -> [Int] { + var node = _peek + var res = Array(repeating: 0, count: size()) + for i in res.indices.reversed() { + res[i] = node!.val + node = node?.next + } + return res + } + } + ``` + +=== "JS" + + ```javascript title="linkedlist_stack.js" + /* 연결 리스트로 구현한 스택 */ + class LinkedListStack { + #stackPeek; // 머리 노드를 스택의 맨 위로 사용한다 + #stkSize = 0; // 스택의 길이 + + constructor() { + this.#stackPeek = null; + } + + /* 스택의 길이 반환 */ + get size() { + return this.#stkSize; + } + + /* 스택이 비어 있는지 판단 */ + isEmpty() { + return this.size === 0; + } + + /* 스택에 삽입 */ + push(num) { + const node = new ListNode(num); + node.next = this.#stackPeek; + this.#stackPeek = node; + this.#stkSize++; + } + + /* 스택에서 꺼내기 */ + pop() { + const num = this.peek(); + this.#stackPeek = this.#stackPeek.next; + this.#stkSize--; + return num; + } + + /* 스택 맨 위 원소 접근 */ + peek() { + if (!this.#stackPeek) throw new Error('스택이 비어 있다'); + return this.#stackPeek.val; + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + toArray() { + let node = this.#stackPeek; + const res = new Array(this.size); + for (let i = res.length - 1; i >= 0; i--) { + res[i] = node.val; + node = node.next; + } + return res; + } + } + ``` + +=== "TS" + + ```typescript title="linkedlist_stack.ts" + /* 연결 리스트로 구현한 스택 */ + class LinkedListStack { + private stackPeek: ListNode | null; // 머리 노드를 스택의 맨 위로 사용한다 + private stkSize: number = 0; // 스택의 길이 + + constructor() { + this.stackPeek = null; + } + + /* 스택의 길이 반환 */ + get size(): number { + return this.stkSize; + } + + /* 스택이 비어 있는지 판단 */ + isEmpty(): boolean { + return this.size === 0; + } + + /* 스택에 삽입 */ + push(num: number): void { + const node = new ListNode(num); + node.next = this.stackPeek; + this.stackPeek = node; + this.stkSize++; + } + + /* 스택에서 꺼내기 */ + pop(): number { + const num = this.peek(); + if (!this.stackPeek) throw new Error('스택이 비어 있다'); + this.stackPeek = this.stackPeek.next; + this.stkSize--; + return num; + } + + /* 스택 맨 위 원소 접근 */ + peek(): number { + if (!this.stackPeek) throw new Error('스택이 비어 있다'); + return this.stackPeek.val; + } + + /* 연결 리스트를 Array로 변환하여 반환 */ + toArray(): number[] { + let node = this.stackPeek; + const res = new Array(this.size); + for (let i = res.length - 1; i >= 0; i--) { + res[i] = node!.val; + node = node!.next; + } + return res; + } + } + ``` + +=== "Dart" + + ```dart title="linkedlist_stack.dart" + /* 연결 리스트 클래스로 구현한 스택 */ + class LinkedListStack { + ListNode? _stackPeek; // 머리 노드를 스택의 맨 위로 사용한다 + int _stkSize = 0; // 스택의 길이 + + LinkedListStack() { + _stackPeek = null; + } + + /* 스택의 길이 반환 */ + int size() { + return _stkSize; + } + + /* 스택이 비어 있는지 판단 */ + bool isEmpty() { + return _stkSize == 0; + } + + /* 스택에 삽입 */ + void push(int _num) { + final ListNode node = ListNode(_num); + node.next = _stackPeek; + _stackPeek = node; + _stkSize++; + } + + /* 스택에서 꺼내기 */ + int pop() { + final int _num = peek(); + _stackPeek = _stackPeek!.next; + _stkSize--; + return _num; + } + + /* 스택 맨 위 원소 접근 */ + int peek() { + if (_stackPeek == null) { + throw Exception("스택이 비어 있다"); + } + return _stackPeek!.val; + } + + /* 연결 리스트를 List로 변환하여 반환 */ + List toList() { + ListNode? node = _stackPeek; + List list = []; + while (node != null) { + list.add(node.val); + node = node.next; + } + list = list.reversed.toList(); + return list; + } + } + ``` + +=== "Rust" + + ```rust title="linkedlist_stack.rs" + /* 연결 리스트로 구현한 스택 */ + #[allow(dead_code)] + pub struct LinkedListStack { + stack_peek: Option>>>, // 머리 노드를 스택의 맨 위로 사용한다 + stk_size: usize, // 스택의 길이 + } + + impl LinkedListStack { + pub fn new() -> Self { + Self { + stack_peek: None, + stk_size: 0, + } + } + + /* 스택의 길이 반환 */ + pub fn size(&self) -> usize { + return self.stk_size; + } + + /* 스택이 비어 있는지 판단 */ + pub fn is_empty(&self) -> bool { + return self.size() == 0; + } + + /* 스택에 삽입 */ + pub fn push(&mut self, num: T) { + let node = ListNode::new(num); + node.borrow_mut().next = self.stack_peek.take(); + self.stack_peek = Some(node); + self.stk_size += 1; + } + + /* 스택에서 꺼내기 */ + pub fn pop(&mut self) -> Option { + self.stack_peek.take().map(|old_head| { + self.stack_peek = old_head.borrow_mut().next.take(); + self.stk_size -= 1; + + old_head.borrow().val + }) + } + + /* 스택 맨 위 원소 접근 */ + pub fn peek(&self) -> Option<&Rc>>> { + self.stack_peek.as_ref() + } + + /* List를 Array로 변환하여 반환 */ + pub fn to_array(&self) -> Vec { + fn _to_array(head: Option<&Rc>>>) -> Vec { + if let Some(node) = head { + let mut nums = _to_array(node.borrow().next.as_ref()); + nums.push(node.borrow().val); + return nums; + } + return Vec::new(); + } + + _to_array(self.peek()) + } + } + ``` + +=== "C" + + ```c title="linkedlist_stack.c" + /* 연결 리스트로 구현한 스택 */ + typedef struct { + ListNode *top; // 머리 노드를 스택의 맨 위로 사용한다 + int size; // 스택의 길이 + } LinkedListStack; + + /* 생성자 */ + LinkedListStack *newLinkedListStack() { + LinkedListStack *s = malloc(sizeof(LinkedListStack)); + s->top = NULL; + s->size = 0; + return s; + } + + /* 소멸자 */ + void delLinkedListStack(LinkedListStack *s) { + while (s->top) { + ListNode *n = s->top->next; + free(s->top); + s->top = n; + } + free(s); + } + + /* 스택의 길이 반환 */ + int size(LinkedListStack *s) { + return s->size; + } + + /* 스택이 비어 있는지 판단 */ + bool isEmpty(LinkedListStack *s) { + return size(s) == 0; + } + + /* 스택에 삽입 */ + void push(LinkedListStack *s, int num) { + ListNode *node = (ListNode *)malloc(sizeof(ListNode)); + node->next = s->top; // 새로 추가한 노드의 포인터 필드를 갱신한다 + node->val = num; // 새로 추가한 노드의 데이터 필드를 갱신한다 + s->top = node; // 스택의 맨 위를 갱신한다 + s->size++; // 스택 크기를 갱신한다 + } + + /* 스택 맨 위 원소 접근 */ + int peek(LinkedListStack *s) { + if (s->size == 0) { + printf("스택이 비어 있다\n"); + return INT_MAX; + } + return s->top->val; + } + + /* 스택에서 꺼내기 */ + int pop(LinkedListStack *s) { + int val = peek(s); + ListNode *tmp = s->top; + s->top = s->top->next; + // 메모리 해제 + free(tmp); + s->size--; + return val; + } + ``` + +=== "Kotlin" + + ```kotlin title="linkedlist_stack.kt" + /* 연결 리스트로 구현한 스택 */ + class LinkedListStack( + private var stackPeek: ListNode? = null, // 머리 노드를 스택의 맨 위로 사용한다 + private var stkSize: Int = 0 // 스택의 길이 + ) { + + /* 스택의 길이 반환 */ + fun size(): Int { + return stkSize + } + + /* 스택이 비어 있는지 판단 */ + fun isEmpty(): Boolean { + return size() == 0 + } + + /* 스택에 삽입 */ + fun push(num: Int) { + val node = ListNode(num) + node.next = stackPeek + stackPeek = node + stkSize++ + } + + /* 스택에서 꺼내기 */ + fun pop(): Int? { + val num = peek() + stackPeek = stackPeek?.next + stkSize-- + return num + } + + /* 스택 맨 위 원소 접근 */ + fun peek(): Int? { + if (isEmpty()) throw IndexOutOfBoundsException() + return stackPeek?._val + } + + /* List를 Array로 변환하여 반환 */ + fun toArray(): IntArray { + var node = stackPeek + val res = IntArray(size()) + for (i in res.size - 1 downTo 0) { + res[i] = node?._val!! + node = node.next + } + return res + } + } + ``` + +=== "Ruby" + + ```ruby title="linkedlist_stack.rb" + ### 연결 리스트로 구현한 스택 ### + class LinkedListStack + attr_reader :size + + ### 생성자 ### + def initialize + @size = 0 + end + + ### 스택이 비어 있는지 판단 ### + def is_empty? + @peek.nil? + end + + ### 스택에 삽입 ### + def push(val) + node = ListNode.new(val) + node.next = @peek + @peek = node + @size += 1 + end + + ### 스택에서 꺼내기 ### + def pop + num = peek + @peek = @peek.next + @size -= 1 + num + end + + ### 스택 맨 위 원소 접근 ### + def peek + raise IndexError, '스택이 비어 있다' if is_empty? + + @peek.val + end + + ### 연결 리스트를 Array로 변환하여 반환 ### + def to_array + arr = [] + node = @peek + while node + arr << node.val + node = node.next + end + arr.reverse + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 2.   배열 기반 구현 + +배열로 스택을 구현할 때는 배열의 끝을 스택의 꼭대기로 볼 수 있다. 아래 그림(5-3)과 같이 삽입과 꺼내기는 각각 배열 끝에 원소를 추가하고 삭제하는 연산에 해당하며, 시간 복잡도는 모두 $O(1)$이다. + +=== "<1>" + ![배열 기반 스택의 삽입과 꺼내기](stack.assets/array_stack_step1.png){ class="animation-figure" } + +=== "<2>" + ![배열 기반 스택에 원소 삽입](stack.assets/array_stack_step2_push.png){ class="animation-figure" } + +=== "<3>" + ![배열 기반 스택에서 원소 꺼내기](stack.assets/array_stack_step3_pop.png){ class="animation-figure" } + +

그림 5-3   배열 기반 스택의 삽입과 꺼내기

+ +스택에 삽입되는 원소가 계속 늘어날 수 있으므로 동적 배열을 사용하면 배열의 용량 확장을 직접 처리하지 않아도 된다. 예제 코드는 다음과 같다. + +=== "Python" + + ```python title="array_stack.py" + class ArrayStack: + """배열로 구현한 스택""" + + def __init__(self): + """생성자""" + self._stack: list[int] = [] + + def size(self) -> int: + """스택의 길이 반환""" + return len(self._stack) + + def is_empty(self) -> bool: + """스택이 비어 있는지 판단""" + return self.size() == 0 + + def push(self, item: int): + """스택에 삽입""" + self._stack.append(item) + + def pop(self) -> int: + """스택에서 꺼내기""" + if self.is_empty(): + raise IndexError("스택이 비어 있다") + return self._stack.pop() + + def peek(self) -> int: + """스택 맨 위 원소 접근""" + if self.is_empty(): + raise IndexError("스택이 비어 있다") + return self._stack[-1] + + def to_list(self) -> list[int]: + """출력에 사용할 리스트 반환""" + return self._stack + ``` + +=== "C++" + + ```cpp title="array_stack.cpp" + /* 배열로 구현한 스택 */ + class ArrayStack { + private: + vector stack; + + public: + /* 스택의 길이 반환 */ + int size() { + return stack.size(); + } + + /* 스택이 비어 있는지 판단 */ + bool isEmpty() { + return stack.size() == 0; + } + + /* 스택에 삽입 */ + void push(int num) { + stack.push_back(num); + } + + /* 스택에서 꺼내기 */ + int pop() { + int num = top(); + stack.pop_back(); + return num; + } + + /* 스택 맨 위 원소 접근 */ + int top() { + if (isEmpty()) + throw out_of_range("스택이 비어 있다"); + return stack.back(); + } + + /* Vector 반환 */ + vector toVector() { + return stack; + } + }; + ``` + +=== "Java" + + ```java title="array_stack.java" + /* 배열로 구현한 스택 */ + class ArrayStack { + private ArrayList stack; + + public ArrayStack() { + // 리스트(동적 배열)를 초기화한다 + stack = new ArrayList<>(); + } + + /* 스택의 길이 반환 */ + public int size() { + return stack.size(); + } + + /* 스택이 비어 있는지 판단 */ + public boolean isEmpty() { + return size() == 0; + } + + /* 스택에 삽입 */ + public void push(int num) { + stack.add(num); + } + + /* 스택에서 꺼내기 */ + public int pop() { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + return stack.remove(size() - 1); + } + + /* 스택 맨 위 원소 접근 */ + public int peek() { + if (isEmpty()) + throw new IndexOutOfBoundsException(); + return stack.get(size() - 1); + } + + /* List를 Array로 변환하여 반환 */ + public Object[] toArray() { + return stack.toArray(); + } + } + ``` + +=== "C#" + + ```csharp title="array_stack.cs" + /* 배열로 구현한 스택 */ + class ArrayStack { + List stack; + public ArrayStack() { + // 리스트(동적 배열)를 초기화한다 + stack = []; + } + + /* 스택의 길이 반환 */ + public int Size() { + return stack.Count; + } + + /* 스택이 비어 있는지 판단 */ + public bool IsEmpty() { + return Size() == 0; + } + + /* 스택에 삽입 */ + public void Push(int num) { + stack.Add(num); + } + + /* 스택에서 꺼내기 */ + public int Pop() { + if (IsEmpty()) + throw new Exception(); + var val = Peek(); + stack.RemoveAt(Size() - 1); + return val; + } + + /* 스택 맨 위 원소 접근 */ + public int Peek() { + if (IsEmpty()) + throw new Exception(); + return stack[Size() - 1]; + } + + /* List를 Array로 변환하여 반환 */ + public int[] ToArray() { + return [.. stack]; + } + } + ``` + +=== "Go" + + ```go title="array_stack.go" + /* 배열로 구현한 스택 */ + type arrayStack struct { + data []int // 데이터 + } + + /* 스택 초기화 */ + func newArrayStack() *arrayStack { + return &arrayStack{ + // 스택의 길이를 0, 용량을 16으로 설정한다 + data: make([]int, 0, 16), + } + } + + /* 스택의 길이 */ + func (s *arrayStack) size() int { + return len(s.data) + } + + /* 스택이 비어 있는지 판단 */ + func (s *arrayStack) isEmpty() bool { + return s.size() == 0 + } + + /* 스택에 삽입 */ + func (s *arrayStack) push(v int) { + // 슬라이스는 용량이 자동으로 확장된다 + s.data = append(s.data, v) + } + + /* 스택에서 꺼내기 */ + func (s *arrayStack) pop() any { + val := s.peek() + s.data = s.data[:len(s.data)-1] + return val + } + + /* 스택 맨 위 원소 반환 */ + func (s *arrayStack) peek() any { + if s.isEmpty() { + return nil + } + val := s.data[len(s.data)-1] + return val + } + + /* 출력에 사용할 Slice 반환 */ + func (s *arrayStack) toSlice() []int { + return s.data + } + ``` + +=== "Swift" + + ```swift title="array_stack.swift" + /* 배열로 구현한 스택 */ + class ArrayStack { + private var stack: [Int] + + init() { + // 리스트(동적 배열)를 초기화한다 + stack = [] + } + + /* 스택의 길이 반환 */ + func size() -> Int { + stack.count + } + + /* 스택이 비어 있는지 판단 */ + func isEmpty() -> Bool { + stack.isEmpty + } + + /* 스택에 삽입 */ + func push(num: Int) { + stack.append(num) + } + + /* 스택에서 꺼내기 */ + @discardableResult + func pop() -> Int { + if isEmpty() { + fatalError("스택이 비어 있다") + } + return stack.removeLast() + } + + /* 스택 맨 위 원소 접근 */ + func peek() -> Int { + if isEmpty() { + fatalError("스택이 비어 있다") + } + return stack.last! + } + + /* List를 Array로 변환하여 반환 */ + func toArray() -> [Int] { + stack + } + } + ``` + +=== "JS" + + ```javascript title="array_stack.js" + /* 배열로 구현한 스택 */ + class ArrayStack { + #stack; + constructor() { + this.#stack = []; + } + + /* 스택의 길이 반환 */ + get size() { + return this.#stack.length; + } + + /* 스택이 비어 있는지 판단 */ + isEmpty() { + return this.#stack.length === 0; + } + + /* 스택에 삽입 */ + push(num) { + this.#stack.push(num); + } + + /* 스택에서 꺼내기 */ + pop() { + if (this.isEmpty()) throw new Error('스택이 비어 있다'); + return this.#stack.pop(); + } + + /* 스택 맨 위 원소 접근 */ + top() { + if (this.isEmpty()) throw new Error('스택이 비어 있다'); + return this.#stack[this.#stack.length - 1]; + } + + /* Array를 반환한다 */ + toArray() { + return this.#stack; + } + } + ``` + +=== "TS" + + ```typescript title="array_stack.ts" + /* 배열로 구현한 스택 */ + class ArrayStack { + private stack: number[]; + constructor() { + this.stack = []; + } + + /* 스택의 길이 반환 */ + get size(): number { + return this.stack.length; + } + + /* 스택이 비어 있는지 판단 */ + isEmpty(): boolean { + return this.stack.length === 0; + } + + /* 스택에 삽입 */ + push(num: number): void { + this.stack.push(num); + } + + /* 스택에서 꺼내기 */ + pop(): number | undefined { + if (this.isEmpty()) throw new Error('스택이 비어 있다'); + return this.stack.pop(); + } + + /* 스택 맨 위 원소 접근 */ + top(): number | undefined { + if (this.isEmpty()) throw new Error('스택이 비어 있다'); + return this.stack[this.stack.length - 1]; + } + + /* Array를 반환한다 */ + toArray() { + return this.stack; + } + } + ``` + +=== "Dart" + + ```dart title="array_stack.dart" + /* 배열로 구현한 스택 */ + class ArrayStack { + late List _stack; + ArrayStack() { + _stack = []; + } + + /* 스택의 길이 반환 */ + int size() { + return _stack.length; + } + + /* 스택이 비어 있는지 판단 */ + bool isEmpty() { + return _stack.isEmpty; + } + + /* 스택에 삽입 */ + void push(int _num) { + _stack.add(_num); + } + + /* 스택에서 꺼내기 */ + int pop() { + if (isEmpty()) { + throw Exception("스택이 비어 있다"); + } + return _stack.removeLast(); + } + + /* 스택 맨 위 원소 접근 */ + int peek() { + if (isEmpty()) { + throw Exception("스택이 비어 있다"); + } + return _stack.last; + } + + /* 스택을 Array로 변환하여 반환 */ + List toArray() => _stack; + } + ``` + +=== "Rust" + + ```rust title="array_stack.rs" + /* 배열로 구현한 스택 */ + struct ArrayStack { + stack: Vec, + } + + impl ArrayStack { + /* 스택 초기화 */ + fn new() -> ArrayStack { + ArrayStack:: { + stack: Vec::::new(), + } + } + + /* 스택의 길이 반환 */ + fn size(&self) -> usize { + self.stack.len() + } + + /* 스택이 비어 있는지 판단 */ + fn is_empty(&self) -> bool { + self.size() == 0 + } + + /* 스택에 삽입 */ + fn push(&mut self, num: T) { + self.stack.push(num); + } + + /* 스택에서 꺼내기 */ + fn pop(&mut self) -> Option { + self.stack.pop() + } + + /* 스택 맨 위 원소 접근 */ + fn peek(&self) -> Option<&T> { + if self.is_empty() { + panic!("스택이 비어 있다") + }; + self.stack.last() + } + + /* &Vec를 반환한다 */ + fn to_array(&self) -> &Vec { + &self.stack + } + } + ``` + +=== "C" + + ```c title="array_stack.c" + /* 배열로 구현한 스택 */ + typedef struct { + int *data; + int size; + } ArrayStack; + + /* 생성자 */ + ArrayStack *newArrayStack() { + ArrayStack *stack = malloc(sizeof(ArrayStack)); + // 용량 확장을 피하도록 큰 용량으로 초기화한다 + stack->data = malloc(sizeof(int) * MAX_SIZE); + stack->size = 0; + return stack; + } + + /* 소멸자 */ + void delArrayStack(ArrayStack *stack) { + free(stack->data); + free(stack); + } + + /* 스택의 길이 반환 */ + int size(ArrayStack *stack) { + return stack->size; + } + + /* 스택이 비어 있는지 판단 */ + bool isEmpty(ArrayStack *stack) { + return stack->size == 0; + } + + /* 스택에 삽입 */ + void push(ArrayStack *stack, int num) { + if (stack->size == MAX_SIZE) { + printf("스택이 가득 찼다\n"); + return; + } + stack->data[stack->size] = num; + stack->size++; + } + + /* 스택 맨 위 원소 접근 */ + int peek(ArrayStack *stack) { + if (stack->size == 0) { + printf("스택이 비어 있다\n"); + return INT_MAX; + } + return stack->data[stack->size - 1]; + } + + /* 스택에서 꺼내기 */ + int pop(ArrayStack *stack) { + int val = peek(stack); + stack->size--; + return val; + } + ``` + +=== "Kotlin" + + ```kotlin title="array_stack.kt" + /* 배열로 구현한 스택 */ + class ArrayStack { + // 리스트(동적 배열)를 초기화한다 + private val stack = mutableListOf() + + /* 스택의 길이 반환 */ + fun size(): Int { + return stack.size + } + + /* 스택이 비어 있는지 판단 */ + fun isEmpty(): Boolean { + return size() == 0 + } + + /* 스택에 삽입 */ + fun push(num: Int) { + stack.add(num) + } + + /* 스택에서 꺼내기 */ + fun pop(): Int { + if (isEmpty()) throw IndexOutOfBoundsException() + return stack.removeAt(size() - 1) + } + + /* 스택 맨 위 원소 접근 */ + fun peek(): Int { + if (isEmpty()) throw IndexOutOfBoundsException() + return stack[size() - 1] + } + + /* List를 Array로 변환하여 반환 */ + fun toArray(): Array { + return stack.toTypedArray() + } + } + ``` + +=== "Ruby" + + ```ruby title="array_stack.rb" + ### 배열로 구현한 스택 ### + class ArrayStack + ### 생성자 ### + def initialize + @stack = [] + end + + ### 스택의 길이 반환 ### + def size + @stack.length + end + + ### 스택이 비어 있는지 판단 ### + def is_empty? + @stack.empty? + end + + ### 스택에 삽입 ### + def push(item) + @stack << item + end + + ### 스택에서 꺼내기 ### + def pop + raise IndexError, '스택이 비어 있다' if is_empty? + + @stack.pop + end + + ### 스택 맨 위 원소 접근 ### + def peek + raise IndexError, '스택이 비어 있다' if is_empty? + + @stack.last + end + + ### 출력에 사용할 리스트 반환 ### + def to_array + @stack + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 5.1.3   두 구현의 비교 + +**지원 연산** + +두 구현 모두 스택 정의에 포함된 모든 연산을 지원한다. 배열 기반 구현은 임의 접근도 지원하지만, 이는 스택의 정의를 벗어나므로 일반적으로 사용하지 않는다. + +**시간 효율** + +배열 기반 구현에서는 삽입과 꺼내기를 미리 할당한 연속 메모리에서 수행한다. 캐시 지역성이 좋아 효율이 높다. 다만 삽입 시 배열 용량을 넘으면 확장이 발생하여 그 삽입 연산의 시간 복잡도는 $O(n)$이 된다. + +연결 리스트 기반 구현은 크기를 유연하게 늘릴 수 있어 배열 확장에 따른 효율 저하가 없다. 하지만 삽입 시 노드 객체를 초기화하고 포인터를 수정해야 하므로 상대적으로 효율이 낮다. 삽입할 원소 자체가 노드 객체라면 초기화 단계를 생략하여 효율을 높일 수 있다. + +따라서 삽입하고 꺼내는 원소가 `int`나 `double` 같은 기본 자료형이라면 다음과 같이 정리할 수 있다. + +- 배열 기반 스택은 용량 확장이 발생할 때 효율이 낮아지지만, 확장은 드물게 일어나므로 평균 효율은 더 높다. +- 연결 리스트 기반 스택은 더 안정적인 효율을 제공한다. + +**공간 효율** + +리스트를 초기화할 때 시스템은 ‘초기 용량’을 할당하며, 이 용량은 실제 필요보다 클 수 있다. 또한 확장 시 보통 2배처럼 일정한 비율로 늘리므로 확장 후 용량도 실제 필요보다 클 수 있다. 따라서 **배열 기반 스택은 어느 정도 공간을 낭비할 수 있다**. + +반면 연결 리스트 노드는 포인터를 추가로 저장해야 하므로 **노드 하나가 차지하는 공간이 상대적으로 크다**. + +따라서 어느 구현이 메모리를 더 절약하는지는 단정할 수 없으며 구체적인 상황에 따라 분석해야 한다. + +## 5.1.4   스택의 대표적인 활용 + +- **브라우저의 뒤로 및 앞으로, 소프트웨어의 실행 취소 및 다시 실행**. 새 웹 페이지를 열 때마다 브라우저는 이전 페이지를 스택에 넣어 뒤로 가기로 돌아갈 수 있게 한다. 뒤로 가기는 실제로 스택에서 원소를 꺼내는 연산이다. 뒤로 가기와 앞으로 가기를 함께 지원하려면 스택 두 개를 사용해야 한다. +- **프로그램 메모리 관리**. 함수가 호출될 때마다 시스템은 스택 맨 위에 스택 프레임을 추가해 함수의 문맥 정보를 기록한다. 재귀 함수에서는 아래로 호출을 이어 가는 단계에서 스택에 계속 넣고, 위로 되돌아오는 단계에서 계속 꺼낸다. diff --git a/ko/docs/chapter_stack_and_queue/summary.md b/ko/docs/chapter_stack_and_queue/summary.md new file mode 100644 index 000000000..195c99e36 --- /dev/null +++ b/ko/docs/chapter_stack_and_queue/summary.md @@ -0,0 +1,35 @@ +--- +comments: true +--- + +# 5.4   요약 + +### 1.   핵심 정리 + +- 스택은 후입선출 원리를 따르는 자료 구조로, 배열이나 연결 리스트로 구현할 수 있다. +- 시간 효율 측면에서 배열 기반 스택의 평균 효율은 더 높지만, 용량 확장이 발생하면 한 번의 삽입 연산의 시간 복잡도가 $O(n)$으로 나빠진다. 반면 연결 리스트 기반 스택은 더 안정적인 효율을 보인다. +- 공간 효율 측면에서 배열 기반 스택은 어느 정도 공간을 낭비할 수 있다. 다만 연결 리스트의 노드 하나는 배열 원소보다 더 많은 메모리를 차지한다. +- 큐는 선입선출 원리를 따르는 자료 구조로, 역시 배열이나 연결 리스트로 구현할 수 있다. 시간 및 공간 효율의 비교 결과는 앞의 스택과 비슷하다. +- 덱은 자유도가 더 높은 큐로, 양 끝에서 원소를 추가하고 삭제할 수 있다. + +### 2.   Q & A + +**Q**: 브라우저의 앞으로 가기와 뒤로 가기는 이중 연결 리스트로 구현되는가? + +브라우저의 앞으로 가기와 뒤로 가기는 본질적으로 ‘스택’을 보여 준다. 사용자가 새 페이지를 방문하면 맨 위에 추가되고, 뒤로 버튼을 누르면 그 페이지가 맨 위에서 꺼내진다. ‘덱’ 절에서 설명했듯 덱을 사용하면 일부 추가 연산을 편리하게 구현할 수 있다. + +**Q**: 스택에서 노드를 꺼낸 뒤 그 노드의 메모리를 해제해야 하는가? + +나중에 꺼낸 노드를 계속 사용해야 한다면 해제할 필요가 없다. 더 이상 사용하지 않는 경우, `Java`와 `Python` 같은 언어는 자동 가비지 컬렉션이 있으므로 직접 해제하지 않아도 된다. `C`와 `C++`에서는 직접 해제해야 한다. + +**Q**: 덱은 두 스택을 이어 붙인 것처럼 보이는데, 어디에 쓰이는가? + +덱은 스택과 큐를 합치거나 두 스택을 이어 붙인 것과 같다. 스택과 큐의 논리를 모두 지니므로 두 자료 구조의 모든 활용을 구현할 수 있고 더 유연하다. + +**Q**: 실행 취소(undo)와 다시 실행(redo)은 구체적으로 어떻게 구현하는가? + +스택 두 개를 사용한다. 스택 `A`는 실행 취소용, 스택 `B`는 다시 실행용이다. + +1. 사용자가 작업을 수행할 때마다 그 작업을 스택 `A`에 넣고 스택 `B`를 비운다. +2. ‘실행 취소’를 수행하면 스택 `A`에서 가장 최근 작업을 꺼내 스택 `B`에 넣는다. +3. ‘다시 실행’을 수행하면 스택 `B`에서 가장 최근 작업을 꺼내 스택 `A`에 넣는다. diff --git a/ko/docs/chapter_tree/array_representation_of_tree.md b/ko/docs/chapter_tree/array_representation_of_tree.md new file mode 100644 index 000000000..eedd14575 --- /dev/null +++ b/ko/docs/chapter_tree/array_representation_of_tree.md @@ -0,0 +1,1348 @@ +--- +comments: true +--- + +# 7.3   이진 트리의 배열 표현 + +연결 리스트로 표현할 때 이진 트리의 저장 단위는 노드 `TreeNode`이며, 노드들은 포인터로 연결된다. 앞 절에서는 이 표현을 사용하는 이진 트리의 기본 연산들을 살펴보았다. + +그렇다면 배열로도 이진 트리를 표현할 수 있을까? 가능하다. + +## 7.3.1   포화 이진 트리 표현 + +먼저 간단한 경우를 살펴보자. 포화 이진 트리의 모든 노드를 레벨 순서 순회 순서대로 배열에 저장하면 각 노드는 고유한 배열 인덱스에 대응한다. + +레벨 순서 순회의 특성에서 부모와 자식 인덱스 사이의 '매핑 공식'을 도출할 수 있다. **노드의 인덱스가 $i$이면 왼쪽 자식의 인덱스는 $2i + 1$, 오른쪽 자식의 인덱스는 $2i + 2$다**. 아래 그림(7-12)은 노드 인덱스 사이의 매핑 관계를 보여 준다. + +![포화 이진 트리의 배열 표현](array_representation_of_tree.assets/array_representation_binary_tree.png){ class="animation-figure" } + +

그림 7-12   포화 이진 트리의 배열 표현

+ +**매핑 공식은 연결 리스트의 노드 참조(포인터)와 같은 역할을 한다**. 배열의 임의의 노드가 주어지면 이 공식으로 왼쪽(오른쪽) 자식에 접근할 수 있다. + +## 7.3.2   임의의 이진 트리 표현 + +포화 이진 트리는 특수한 경우이며, 일반적인 이진 트리의 중간 레벨에는 `None`이 많이 존재한다. 레벨 순서 순회 결과에는 이 `None`들이 포함되지 않으므로 결과만으로는 개수와 위치를 알 수 없다. **즉, 같은 레벨 순서 순회 결과에 여러 이진 트리 구조가 대응할 수 있다**. + +아래 그림(7-13)처럼 포화 이진 트리가 아니면 앞의 배열 표현 방법은 더 이상 유효하지 않다. + +![하나의 레벨 순서 순회 결과에 대응하는 여러 이진 트리](array_representation_of_tree.assets/array_representation_without_empty.png){ class="animation-figure" } + +

그림 7-13   하나의 레벨 순서 순회 결과에 대응하는 여러 이진 트리

+ +이를 해결하려면 **레벨 순서 순회 결과에 모든 `None`을 명시적으로 넣을 수 있다**. 아래 그림(7-14)처럼 처리하면 순회 결과가 이진 트리를 유일하게 나타낸다. 예시 코드는 다음과 같다. + +=== "Python" + + ```python title="" + # 이진 트리의 배열 표현 + # None으로 빈자리 표시 + tree = [1, 2, 3, 4, None, 6, 7, 8, 9, None, None, 12, None, None, 15] + ``` + +=== "C++" + + ```cpp title="" + /* 이진 트리의 배열 표현 */ + // int의 최댓값 INT_MAX로 빈자리 표시 + vector tree = {1, 2, 3, 4, INT_MAX, 6, 7, 8, 9, INT_MAX, INT_MAX, 12, INT_MAX, INT_MAX, 15}; + ``` + +=== "Java" + + ```java title="" + /* 이진 트리의 배열 표현 */ + // int의 래퍼 클래스 Integer를 사용하면 null로 빈자리를 표시할 수 있다 + Integer[] tree = { 1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15 }; + ``` + +=== "C#" + + ```csharp title="" + /* 이진 트리의 배열 표현 */ + // 널 허용 타입 int?를 사용하면 null로 빈자리를 표시할 수 있다 + int?[] tree = [1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15]; + ``` + +=== "Go" + + ```go title="" + /* 이진 트리의 배열 표현 */ + // any 타입의 슬라이스를 사용하면 nil로 빈자리를 표시할 수 있다 + tree := []any{1, 2, 3, 4, nil, 6, 7, 8, 9, nil, nil, 12, nil, nil, 15} + ``` + +=== "Swift" + + ```swift title="" + /* 이진 트리의 배열 표현 */ + // 널 허용 타입 Int?를 사용하면 nil로 빈자리를 표시할 수 있다 + let tree: [Int?] = [1, 2, 3, 4, nil, 6, 7, 8, 9, nil, nil, 12, nil, nil, 15] + ``` + +=== "JS" + + ```javascript title="" + /* 이진 트리의 배열 표현 */ + // null로 빈자리 표시 + let tree = [1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15]; + ``` + +=== "TS" + + ```typescript title="" + /* 이진 트리의 배열 표현 */ + // null로 빈자리 표시 + let tree: (number | null)[] = [1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15]; + ``` + +=== "Dart" + + ```dart title="" + /* 이진 트리의 배열 표현 */ + // 널 허용 타입 int?를 사용하면 null로 빈자리를 표시할 수 있다 + List tree = [1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15]; + ``` + +=== "Rust" + + ```rust title="" + /* 이진 트리의 배열 표현 */ + // None으로 빈자리 표시 + let tree = [Some(1), Some(2), Some(3), Some(4), None, Some(6), Some(7), Some(8), Some(9), None, None, Some(12), None, None, Some(15)]; + ``` + +=== "C" + + ```c title="" + /* 이진 트리의 배열 표현 */ + // int의 최댓값으로 빈자리를 표시하므로 노드 값은 INT_MAX이면 안 된다 + int tree[] = {1, 2, 3, 4, INT_MAX, 6, 7, 8, 9, INT_MAX, INT_MAX, 12, INT_MAX, INT_MAX, 15}; + ``` + +=== "Kotlin" + + ```kotlin title="" + /* 이진 트리의 배열 표현 */ + // null로 빈자리 표시 + val tree = arrayOf( 1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15 ) + ``` + +=== "Ruby" + + ```ruby title="" + ### 이진 트리의 배열 표현 ### + # nil로 빈자리 표시 + tree = [1, 2, 3, 4, nil, 6, 7, 8, 9, nil, nil, 12, nil, nil, 15] + ``` + +![임의의 이진 트리의 배열 표현](array_representation_of_tree.assets/array_representation_with_empty.png){ class="animation-figure" } + +

그림 7-14   임의의 이진 트리의 배열 표현

+ +**완전 이진 트리는 배열로 표현하기에 매우 적합하다**. 정의를 떠올려 보면 `None`은 맨 아래 레벨의 오른쪽에만 있으므로 **레벨 순서 순회 결과의 끝에만 나타난다**. + +따라서 완전 이진 트리를 배열로 표현할 때는 모든 `None`의 저장을 생략할 수 있어 매우 편리하다. 아래 그림(7-15)은 그 예다. + +![완전 이진 트리의 배열 표현](array_representation_of_tree.assets/array_representation_complete_binary_tree.png){ class="animation-figure" } + +

그림 7-15   완전 이진 트리의 배열 표현

+ +다음 코드는 배열로 표현한 이진 트리를 구현하며 아래 연산들을 포함한다. + +- 주어진 노드의 값, 왼쪽(오른쪽) 자식, 부모 노드를 구한다. +- 전위, 중위, 후위, 레벨 순서 순회 결과를 구한다. + +=== "Python" + + ```python title="array_binary_tree.py" + class ArrayBinaryTree: + """배열로 표현한 이진 트리 클래스""" + + def __init__(self, arr: list[int | None]): + """생성자""" + self._tree = list(arr) + + def size(self): + """리스트 용량""" + return len(self._tree) + + def val(self, i: int) -> int | None: + """인덱스가 i인 노드의 값 반환""" + # 인덱스가 범위를 벗어나면 빈칸을 나타내는 None을 반환한다 + if i < 0 or i >= self.size(): + return None + return self._tree[i] + + def left(self, i: int) -> int | None: + """인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환""" + return 2 * i + 1 + + def right(self, i: int) -> int | None: + """인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환""" + return 2 * i + 2 + + def parent(self, i: int) -> int | None: + """인덱스가 i인 노드의 부모 노드 인덱스 반환""" + return (i - 1) // 2 + + def level_order(self) -> list[int]: + """레벨 순서 순회""" + self.res = [] + # 배열을 직접 순회한다 + for i in range(self.size()): + if self.val(i) is not None: + self.res.append(self.val(i)) + return self.res + + def dfs(self, i: int, order: str): + """깊이 우선 순회""" + if self.val(i) is None: + return + # 전위 순회 + if order == "pre": + self.res.append(self.val(i)) + self.dfs(self.left(i), order) + # 중위 순회 + if order == "in": + self.res.append(self.val(i)) + self.dfs(self.right(i), order) + # 후위 순회 + if order == "post": + self.res.append(self.val(i)) + + def pre_order(self) -> list[int]: + """전위 순회""" + self.res = [] + self.dfs(0, order="pre") + return self.res + + def in_order(self) -> list[int]: + """중위 순회""" + self.res = [] + self.dfs(0, order="in") + return self.res + + def post_order(self) -> list[int]: + """후위 순회""" + self.res = [] + self.dfs(0, order="post") + return self.res + ``` + +=== "C++" + + ```cpp title="array_binary_tree.cpp" + /* 배열로 표현한 이진 트리 클래스 */ + class ArrayBinaryTree { + public: + /* 생성자 */ + ArrayBinaryTree(vector arr) { + tree = arr; + } + + /* 리스트 용량 */ + int size() { + return tree.size(); + } + + /* 인덱스가 i인 노드의 값 반환 */ + int val(int i) { + // 인덱스가 범위를 벗어나면 빈자리를 나타내는 INT_MAX를 반환한다 + if (i < 0 || i >= size()) + return INT_MAX; + return tree[i]; + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + int left(int i) { + return 2 * i + 1; + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + int right(int i) { + return 2 * i + 2; + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + int parent(int i) { + return (i - 1) / 2; + } + + /* 레벨 순서 순회 */ + vector levelOrder() { + vector res; + // 배열을 직접 순회한다 + for (int i = 0; i < size(); i++) { + if (val(i) != INT_MAX) + res.push_back(val(i)); + } + return res; + } + + /* 전위 순회 */ + vector preOrder() { + vector res; + dfs(0, "pre", res); + return res; + } + + /* 중위 순회 */ + vector inOrder() { + vector res; + dfs(0, "in", res); + return res; + } + + /* 후위 순회 */ + vector postOrder() { + vector res; + dfs(0, "post", res); + return res; + } + + private: + vector tree; + + /* 깊이 우선 순회 */ + void dfs(int i, string order, vector &res) { + // 빈칸이면 반환한다 + if (val(i) == INT_MAX) + return; + // 전위 순회 + if (order == "pre") + res.push_back(val(i)); + dfs(left(i), order, res); + // 중위 순회 + if (order == "in") + res.push_back(val(i)); + dfs(right(i), order, res); + // 후위 순회 + if (order == "post") + res.push_back(val(i)); + } + }; + ``` + +=== "Java" + + ```java title="array_binary_tree.java" + /* 배열로 표현한 이진 트리 클래스 */ + class ArrayBinaryTree { + private List tree; + + /* 생성자 */ + public ArrayBinaryTree(List arr) { + tree = new ArrayList<>(arr); + } + + /* 리스트 용량 */ + public int size() { + return tree.size(); + } + + /* 인덱스가 i인 노드의 값 반환 */ + public Integer val(int i) { + // 인덱스가 범위를 벗어나면 빈칸을 나타내는 null을 반환한다 + if (i < 0 || i >= size()) + return null; + return tree.get(i); + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + public Integer left(int i) { + return 2 * i + 1; + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + public Integer right(int i) { + return 2 * i + 2; + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + public Integer parent(int i) { + return (i - 1) / 2; + } + + /* 레벨 순서 순회 */ + public List levelOrder() { + List res = new ArrayList<>(); + // 배열을 직접 순회한다 + for (int i = 0; i < size(); i++) { + if (val(i) != null) + res.add(val(i)); + } + return res; + } + + /* 깊이 우선 순회 */ + private void dfs(Integer i, String order, List res) { + // 빈칸이면 반환한다 + if (val(i) == null) + return; + // 전위 순회 + if ("pre".equals(order)) + res.add(val(i)); + dfs(left(i), order, res); + // 중위 순회 + if ("in".equals(order)) + res.add(val(i)); + dfs(right(i), order, res); + // 후위 순회 + if ("post".equals(order)) + res.add(val(i)); + } + + /* 전위 순회 */ + public List preOrder() { + List res = new ArrayList<>(); + dfs(0, "pre", res); + return res; + } + + /* 중위 순회 */ + public List inOrder() { + List res = new ArrayList<>(); + dfs(0, "in", res); + return res; + } + + /* 후위 순회 */ + public List postOrder() { + List res = new ArrayList<>(); + dfs(0, "post", res); + return res; + } + } + ``` + +=== "C#" + + ```csharp title="array_binary_tree.cs" + /* 배열로 표현한 이진 트리 클래스 */ + class ArrayBinaryTree(List arr) { + List tree = new(arr); + + /* 리스트 용량 */ + public int Size() { + return tree.Count; + } + + /* 인덱스가 i인 노드의 값 반환 */ + public int? Val(int i) { + // 인덱스가 범위를 벗어나면 빈칸을 나타내는 null을 반환한다 + if (i < 0 || i >= Size()) + return null; + return tree[i]; + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + public int Left(int i) { + return 2 * i + 1; + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + public int Right(int i) { + return 2 * i + 2; + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + public int Parent(int i) { + return (i - 1) / 2; + } + + /* 레벨 순서 순회 */ + public List LevelOrder() { + List res = []; + // 배열을 직접 순회한다 + for (int i = 0; i < Size(); i++) { + if (Val(i).HasValue) + res.Add(Val(i)!.Value); + } + return res; + } + + /* 깊이 우선 순회 */ + void DFS(int i, string order, List res) { + // 빈칸이면 반환한다 + if (!Val(i).HasValue) + return; + // 전위 순회 + if (order == "pre") + res.Add(Val(i)!.Value); + DFS(Left(i), order, res); + // 중위 순회 + if (order == "in") + res.Add(Val(i)!.Value); + DFS(Right(i), order, res); + // 후위 순회 + if (order == "post") + res.Add(Val(i)!.Value); + } + + /* 전위 순회 */ + public List PreOrder() { + List res = []; + DFS(0, "pre", res); + return res; + } + + /* 중위 순회 */ + public List InOrder() { + List res = []; + DFS(0, "in", res); + return res; + } + + /* 후위 순회 */ + public List PostOrder() { + List res = []; + DFS(0, "post", res); + return res; + } + } + ``` + +=== "Go" + + ```go title="array_binary_tree.go" + /* 배열로 표현한 이진 트리 클래스 */ + type arrayBinaryTree struct { + tree []any + } + + /* 생성자 */ + func newArrayBinaryTree(arr []any) *arrayBinaryTree { + return &arrayBinaryTree{ + tree: arr, + } + } + + /* 리스트 용량 */ + func (abt *arrayBinaryTree) size() int { + return len(abt.tree) + } + + /* 인덱스가 i인 노드의 값 반환 */ + func (abt *arrayBinaryTree) val(i int) any { + // 인덱스가 범위를 벗어나면 빈칸을 나타내는 null을 반환한다 + if i < 0 || i >= abt.size() { + return nil + } + return abt.tree[i] + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + func (abt *arrayBinaryTree) left(i int) int { + return 2*i + 1 + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + func (abt *arrayBinaryTree) right(i int) int { + return 2*i + 2 + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + func (abt *arrayBinaryTree) parent(i int) int { + return (i - 1) / 2 + } + + /* 레벨 순서 순회 */ + func (abt *arrayBinaryTree) levelOrder() []any { + var res []any + // 배열을 직접 순회한다 + for i := 0; i < abt.size(); i++ { + if abt.val(i) != nil { + res = append(res, abt.val(i)) + } + } + return res + } + + /* 깊이 우선 순회 */ + func (abt *arrayBinaryTree) dfs(i int, order string, res *[]any) { + // 빈칸이면 반환한다 + if abt.val(i) == nil { + return + } + // 전위 순회 + if order == "pre" { + *res = append(*res, abt.val(i)) + } + abt.dfs(abt.left(i), order, res) + // 중위 순회 + if order == "in" { + *res = append(*res, abt.val(i)) + } + abt.dfs(abt.right(i), order, res) + // 후위 순회 + if order == "post" { + *res = append(*res, abt.val(i)) + } + } + + /* 전위 순회 */ + func (abt *arrayBinaryTree) preOrder() []any { + var res []any + abt.dfs(0, "pre", &res) + return res + } + + /* 중위 순회 */ + func (abt *arrayBinaryTree) inOrder() []any { + var res []any + abt.dfs(0, "in", &res) + return res + } + + /* 후위 순회 */ + func (abt *arrayBinaryTree) postOrder() []any { + var res []any + abt.dfs(0, "post", &res) + return res + } + ``` + +=== "Swift" + + ```swift title="array_binary_tree.swift" + /* 배열로 표현한 이진 트리 클래스 */ + class ArrayBinaryTree { + private var tree: [Int?] + + /* 생성자 */ + init(arr: [Int?]) { + tree = arr + } + + /* 리스트 용량 */ + func size() -> Int { + tree.count + } + + /* 인덱스가 i인 노드의 값 반환 */ + func val(i: Int) -> Int? { + // 인덱스가 범위를 벗어나면 빈칸을 나타내는 null을 반환한다 + if i < 0 || i >= size() { + return nil + } + return tree[i] + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + func left(i: Int) -> Int { + 2 * i + 1 + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + func right(i: Int) -> Int { + 2 * i + 2 + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + func parent(i: Int) -> Int { + (i - 1) / 2 + } + + /* 레벨 순서 순회 */ + func levelOrder() -> [Int] { + var res: [Int] = [] + // 배열을 직접 순회한다 + for i in 0 ..< size() { + if let val = val(i: i) { + res.append(val) + } + } + return res + } + + /* 깊이 우선 순회 */ + private func dfs(i: Int, order: String, res: inout [Int]) { + // 빈칸이면 반환한다 + guard let val = val(i: i) else { + return + } + // 전위 순회 + if order == "pre" { + res.append(val) + } + dfs(i: left(i: i), order: order, res: &res) + // 중위 순회 + if order == "in" { + res.append(val) + } + dfs(i: right(i: i), order: order, res: &res) + // 후위 순회 + if order == "post" { + res.append(val) + } + } + + /* 전위 순회 */ + func preOrder() -> [Int] { + var res: [Int] = [] + dfs(i: 0, order: "pre", res: &res) + return res + } + + /* 중위 순회 */ + func inOrder() -> [Int] { + var res: [Int] = [] + dfs(i: 0, order: "in", res: &res) + return res + } + + /* 후위 순회 */ + func postOrder() -> [Int] { + var res: [Int] = [] + dfs(i: 0, order: "post", res: &res) + return res + } + } + ``` + +=== "JS" + + ```javascript title="array_binary_tree.js" + /* 배열로 표현한 이진 트리 클래스 */ + class ArrayBinaryTree { + #tree; + + /* 생성자 */ + constructor(arr) { + this.#tree = arr; + } + + /* 리스트 용량 */ + size() { + return this.#tree.length; + } + + /* 인덱스가 i인 노드의 값 반환 */ + val(i) { + // 인덱스가 범위를 벗어나면 빈칸을 나타내는 null을 반환한다 + if (i < 0 || i >= this.size()) return null; + return this.#tree[i]; + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + left(i) { + return 2 * i + 1; + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + right(i) { + return 2 * i + 2; + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + parent(i) { + return Math.floor((i - 1) / 2); // 나눗셈의 결과를 내림한다 + } + + /* 레벨 순서 순회 */ + levelOrder() { + let res = []; + // 배열을 직접 순회한다 + for (let i = 0; i < this.size(); i++) { + if (this.val(i) !== null) res.push(this.val(i)); + } + return res; + } + + /* 깊이 우선 순회 */ + #dfs(i, order, res) { + // 빈칸이면 반환한다 + if (this.val(i) === null) return; + // 전위 순회 + if (order === 'pre') res.push(this.val(i)); + this.#dfs(this.left(i), order, res); + // 중위 순회 + if (order === 'in') res.push(this.val(i)); + this.#dfs(this.right(i), order, res); + // 후위 순회 + if (order === 'post') res.push(this.val(i)); + } + + /* 전위 순회 */ + preOrder() { + const res = []; + this.#dfs(0, 'pre', res); + return res; + } + + /* 중위 순회 */ + inOrder() { + const res = []; + this.#dfs(0, 'in', res); + return res; + } + + /* 후위 순회 */ + postOrder() { + const res = []; + this.#dfs(0, 'post', res); + return res; + } + } + ``` + +=== "TS" + + ```typescript title="array_binary_tree.ts" + /* 배열로 표현한 이진 트리 클래스 */ + class ArrayBinaryTree { + #tree: (number | null)[]; + + /* 생성자 */ + constructor(arr: (number | null)[]) { + this.#tree = arr; + } + + /* 리스트 용량 */ + size(): number { + return this.#tree.length; + } + + /* 인덱스가 i인 노드의 값 반환 */ + val(i: number): number | null { + // 인덱스가 범위를 벗어나면 빈칸을 나타내는 null을 반환한다 + if (i < 0 || i >= this.size()) return null; + return this.#tree[i]; + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + left(i: number): number { + return 2 * i + 1; + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + right(i: number): number { + return 2 * i + 2; + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + parent(i: number): number { + return Math.floor((i - 1) / 2); // 나눗셈의 결과를 내림한다 + } + + /* 레벨 순서 순회 */ + levelOrder(): number[] { + let res = []; + // 배열을 직접 순회한다 + for (let i = 0; i < this.size(); i++) { + if (this.val(i) !== null) res.push(this.val(i)); + } + return res; + } + + /* 깊이 우선 순회 */ + #dfs(i: number, order: Order, res: (number | null)[]): void { + // 빈칸이면 반환한다 + if (this.val(i) === null) return; + // 전위 순회 + if (order === 'pre') res.push(this.val(i)); + this.#dfs(this.left(i), order, res); + // 중위 순회 + if (order === 'in') res.push(this.val(i)); + this.#dfs(this.right(i), order, res); + // 후위 순회 + if (order === 'post') res.push(this.val(i)); + } + + /* 전위 순회 */ + preOrder(): (number | null)[] { + const res = []; + this.#dfs(0, 'pre', res); + return res; + } + + /* 중위 순회 */ + inOrder(): (number | null)[] { + const res = []; + this.#dfs(0, 'in', res); + return res; + } + + /* 후위 순회 */ + postOrder(): (number | null)[] { + const res = []; + this.#dfs(0, 'post', res); + return res; + } + } + ``` + +=== "Dart" + + ```dart title="array_binary_tree.dart" + /* 배열로 표현한 이진 트리 클래스 */ + class ArrayBinaryTree { + late List _tree; + + /* 생성자 */ + ArrayBinaryTree(this._tree); + + /* 리스트 용량 */ + int size() { + return _tree.length; + } + + /* 인덱스가 i인 노드의 값 반환 */ + int? val(int i) { + // 인덱스가 범위를 벗어나면 빈칸을 나타내는 null을 반환한다 + if (i < 0 || i >= size()) { + return null; + } + return _tree[i]; + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + int? left(int i) { + return 2 * i + 1; + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + int? right(int i) { + return 2 * i + 2; + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + int? parent(int i) { + return (i - 1) ~/ 2; + } + + /* 레벨 순서 순회 */ + List levelOrder() { + List res = []; + for (int i = 0; i < size(); i++) { + if (val(i) != null) { + res.add(val(i)!); + } + } + return res; + } + + /* 깊이 우선 순회 */ + void dfs(int i, String order, List res) { + // 빈칸이면 반환한다 + if (val(i) == null) { + return; + } + // 전위 순회 + if (order == 'pre') { + res.add(val(i)); + } + dfs(left(i)!, order, res); + // 중위 순회 + if (order == 'in') { + res.add(val(i)); + } + dfs(right(i)!, order, res); + // 후위 순회 + if (order == 'post') { + res.add(val(i)); + } + } + + /* 전위 순회 */ + List preOrder() { + List res = []; + dfs(0, 'pre', res); + return res; + } + + /* 중위 순회 */ + List inOrder() { + List res = []; + dfs(0, 'in', res); + return res; + } + + /* 후위 순회 */ + List postOrder() { + List res = []; + dfs(0, 'post', res); + return res; + } + } + ``` + +=== "Rust" + + ```rust title="array_binary_tree.rs" + /* 배열로 표현한 이진 트리 클래스 */ + struct ArrayBinaryTree { + tree: Vec>, + } + + impl ArrayBinaryTree { + /* 생성자 */ + fn new(arr: Vec>) -> Self { + Self { tree: arr } + } + + /* 리스트 용량 */ + fn size(&self) -> i32 { + self.tree.len() as i32 + } + + /* 인덱스가 i인 노드의 값 반환 */ + fn val(&self, i: i32) -> Option { + // 인덱스가 범위를 벗어나면 빈칸을 나타내는 None을 반환한다 + if i < 0 || i >= self.size() { + None + } else { + self.tree[i as usize] + } + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + fn left(&self, i: i32) -> i32 { + 2 * i + 1 + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + fn right(&self, i: i32) -> i32 { + 2 * i + 2 + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + fn parent(&self, i: i32) -> i32 { + (i - 1) / 2 + } + + /* 레벨 순서 순회 */ + fn level_order(&self) -> Vec { + self.tree.iter().filter_map(|&x| x).collect() + } + + /* 깊이 우선 순회 */ + fn dfs(&self, i: i32, order: &'static str, res: &mut Vec) { + if self.val(i).is_none() { + return; + } + let val = self.val(i).unwrap(); + // 전위 순회 + if order == "pre" { + res.push(val); + } + self.dfs(self.left(i), order, res); + // 중위 순회 + if order == "in" { + res.push(val); + } + self.dfs(self.right(i), order, res); + // 후위 순회 + if order == "post" { + res.push(val); + } + } + + /* 전위 순회 */ + fn pre_order(&self) -> Vec { + let mut res = vec![]; + self.dfs(0, "pre", &mut res); + res + } + + /* 중위 순회 */ + fn in_order(&self) -> Vec { + let mut res = vec![]; + self.dfs(0, "in", &mut res); + res + } + + /* 후위 순회 */ + fn post_order(&self) -> Vec { + let mut res = vec![]; + self.dfs(0, "post", &mut res); + res + } + } + ``` + +=== "C" + + ```c title="array_binary_tree.c" + /* 배열로 표현한 이진 트리 구조체 */ + typedef struct { + int *tree; + int size; + } ArrayBinaryTree; + + /* 생성자 */ + ArrayBinaryTree *newArrayBinaryTree(int *arr, int arrSize) { + ArrayBinaryTree *abt = (ArrayBinaryTree *)malloc(sizeof(ArrayBinaryTree)); + abt->tree = malloc(sizeof(int) * arrSize); + memcpy(abt->tree, arr, sizeof(int) * arrSize); + abt->size = arrSize; + return abt; + } + + /* 소멸자 */ + void delArrayBinaryTree(ArrayBinaryTree *abt) { + free(abt->tree); + free(abt); + } + + /* 리스트 용량 */ + int size(ArrayBinaryTree *abt) { + return abt->size; + } + + /* 인덱스가 i인 노드의 값 반환 */ + int val(ArrayBinaryTree *abt, int i) { + // 인덱스가 범위를 벗어나면 빈자리를 나타내는 INT_MAX를 반환한다 + if (i < 0 || i >= size(abt)) + return INT_MAX; + return abt->tree[i]; + } + + /* 레벨 순서 순회 */ + int *levelOrder(ArrayBinaryTree *abt, int *returnSize) { + int *res = (int *)malloc(sizeof(int) * size(abt)); + int index = 0; + // 배열을 직접 순회한다 + for (int i = 0; i < size(abt); i++) { + if (val(abt, i) != INT_MAX) + res[index++] = val(abt, i); + } + *returnSize = index; + return res; + } + + /* 깊이 우선 순회 */ + void dfs(ArrayBinaryTree *abt, int i, char *order, int *res, int *index) { + // 빈칸이면 반환한다 + if (val(abt, i) == INT_MAX) + return; + // 전위 순회 + if (strcmp(order, "pre") == 0) + res[(*index)++] = val(abt, i); + dfs(abt, left(i), order, res, index); + // 중위 순회 + if (strcmp(order, "in") == 0) + res[(*index)++] = val(abt, i); + dfs(abt, right(i), order, res, index); + // 후위 순회 + if (strcmp(order, "post") == 0) + res[(*index)++] = val(abt, i); + } + + /* 전위 순회 */ + int *preOrder(ArrayBinaryTree *abt, int *returnSize) { + int *res = (int *)malloc(sizeof(int) * size(abt)); + int index = 0; + dfs(abt, 0, "pre", res, &index); + *returnSize = index; + return res; + } + + /* 중위 순회 */ + int *inOrder(ArrayBinaryTree *abt, int *returnSize) { + int *res = (int *)malloc(sizeof(int) * size(abt)); + int index = 0; + dfs(abt, 0, "in", res, &index); + *returnSize = index; + return res; + } + + /* 후위 순회 */ + int *postOrder(ArrayBinaryTree *abt, int *returnSize) { + int *res = (int *)malloc(sizeof(int) * size(abt)); + int index = 0; + dfs(abt, 0, "post", res, &index); + *returnSize = index; + return res; + } + ``` + +=== "Kotlin" + + ```kotlin title="array_binary_tree.kt" + /* 배열로 표현한 이진 트리 클래스 */ + class ArrayBinaryTree(val tree: MutableList) { + /* 리스트 용량 */ + fun size(): Int { + return tree.size + } + + /* 인덱스가 i인 노드의 값 반환 */ + fun _val(i: Int): Int? { + // 인덱스가 범위를 벗어나면 빈칸을 나타내는 null을 반환한다 + if (i < 0 || i >= size()) return null + return tree[i] + } + + /* 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 */ + fun left(i: Int): Int { + return 2 * i + 1 + } + + /* 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 */ + fun right(i: Int): Int { + return 2 * i + 2 + } + + /* 인덱스가 i인 노드의 부모 노드 인덱스 반환 */ + fun parent(i: Int): Int { + return (i - 1) / 2 + } + + /* 레벨 순서 순회 */ + fun levelOrder(): MutableList { + val res = mutableListOf() + // 배열을 직접 순회한다 + for (i in 0..) { + // 빈칸이면 반환한다 + if (_val(i) == null) + return + // 전위 순회 + if ("pre" == order) + res.add(_val(i)) + dfs(left(i), order, res) + // 중위 순회 + if ("in" == order) + res.add(_val(i)) + dfs(right(i), order, res) + // 후위 순회 + if ("post" == order) + res.add(_val(i)) + } + + /* 전위 순회 */ + fun preOrder(): MutableList { + val res = mutableListOf() + dfs(0, "pre", res) + return res + } + + /* 중위 순회 */ + fun inOrder(): MutableList { + val res = mutableListOf() + dfs(0, "in", res) + return res + } + + /* 후위 순회 */ + fun postOrder(): MutableList { + val res = mutableListOf() + dfs(0, "post", res) + return res + } + } + ``` + +=== "Ruby" + + ```ruby title="array_binary_tree.rb" + ### 배열로 표현한 이진 트리 클래스 ### + class ArrayBinaryTree + ### 생성자 ### + def initialize(arr) + @tree = arr.to_a + end + + ### 리스트 용량 ### + def size + @tree.length + end + + ### 인덱스가 i인 노드의 값 반환 ### + def val(i) + # 인덱스가 범위를 벗어나면 빈칸을 나타내는 nil을 반환한다 + return if i < 0 || i >= size + + @tree[i] + end + + ### 인덱스가 i인 노드의 왼쪽 자식 노드 인덱스 반환 ### + def left(i) + 2 * i + 1 + end + + ### 인덱스가 i인 노드의 오른쪽 자식 노드 인덱스 반환 ### + def right(i) + 2 * i + 2 + end + + ### 인덱스가 i인 노드의 부모 노드 인덱스 반환 ### + def parent(i) + (i - 1) / 2 + end + + ### 레벨 순서 순회 ### + def level_order + @res = [] + + # 배열을 직접 순회한다 + for i in 0...size + @res << val(i) unless val(i).nil? + end + + @res + end + + ### 깊이 우선 순회 ### + def dfs(i, order) + return if val(i).nil? + # 전위 순회 + @res << val(i) if order == :pre + dfs(left(i), order) + # 중위 순회 + @res << val(i) if order == :in + dfs(right(i), order) + # 후위 순회 + @res << val(i) if order == :post + end + + ### 전위 순회 ### + def pre_order + @res = [] + dfs(0, :pre) + @res + end + + ### 중위 순회 ### + def in_order + @res = [] + dfs(0, :in) + @res + end + + ### 후위 순회 ### + def post_order + @res = [] + dfs(0, :post) + @res + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +## 7.3.3   장점과 한계 + +이진 트리의 배열 표현에는 다음 장점이 있다. + +- 배열은 연속된 메모리에 저장되어 캐시 친화적이며, 접근과 순회가 빠르다. +- 포인터를 저장할 필요가 없어 공간을 절약한다. +- 노드에 임의 접근할 수 있다. + +하지만 다음과 같은 한계도 있다. + +- 연속된 메모리가 필요하므로 데이터가 지나치게 많은 트리를 저장하기에는 적합하지 않다. +- 노드를 추가하거나 삭제하려면 배열의 삽입과 삭제 연산을 수행해야 하므로 효율이 낮다. +- 트리에 `None`이 많으면 배열에서 실제 노드 데이터가 차지하는 비율이 낮아 공간 활용률이 떨어진다. diff --git a/ko/docs/chapter_tree/avl_tree.md b/ko/docs/chapter_tree/avl_tree.md new file mode 100644 index 000000000..b73b7c619 --- /dev/null +++ b/ko/docs/chapter_tree/avl_tree.md @@ -0,0 +1,2649 @@ +--- +comments: true +--- + +# 7.5   AVL 트리 * + +'이진 탐색 트리' 절에서 삽입과 삭제를 여러 번 수행하면 트리가 연결 리스트로 퇴화할 수 있다고 설명했다. 이 경우 모든 연산의 시간 복잡도가 $O(\log n)$에서 $O(n)$으로 저하된다. + +아래 그림(7-24)의 이진 탐색 트리는 노드를 두 번 삭제하면 연결 리스트로 퇴화한다. + +![노드 삭제 후 퇴화한 AVL 트리](avl_tree.assets/avltree_degradation_from_removing_node.png){ class="animation-figure" } + +

그림 7-24   노드 삭제 후 퇴화한 AVL 트리

+ +또한 아래 그림(7-25)의 포화 이진 트리에 노드 두 개를 삽입하면 왼쪽으로 심하게 기울어 탐색의 시간 복잡도도 저하된다. + +![노드 삽입 후 퇴화한 AVL 트리](avl_tree.assets/avltree_degradation_from_inserting_node.png){ class="animation-figure" } + +

그림 7-25   노드 삽입 후 퇴화한 AVL 트리

+ +1962년 G. M. Adelson-Velsky와 E. M. Landis는 논문 'An algorithm for the organization of information'에서 AVL 트리를 제안했다. 논문은 노드를 계속 추가하거나 삭제해도 트리가 퇴화하지 않게 하는 일련의 연산을 상세히 설명하여, 각 연산의 시간 복잡도를 $O(\log n)$으로 유지하도록 했다. 즉, 추가, 삭제, 조회, 수정이 잦은 상황에서도 효율적인 데이터 연산 성능을 유지하므로 활용 가치가 높다. + +## 7.5.1   AVL 트리의 주요 용어 + +AVL 트리는 이진 탐색 트리이면서 균형 이진 트리다. 두 종류의 모든 성질을 함께 만족하므로 균형 이진 탐색 트리(balanced binary search tree)다. + +### 1.   노드 높이 + +AVL 트리 연산에는 노드 높이가 필요하므로 노드 클래스에 `height` 변수를 추가해야 한다. + +=== "Python" + + ```python title="" + class TreeNode: + """AVL 트리 노드 클래스""" + def __init__(self, val: int): + self.val: int = val # 노드 값 + self.height: int = 0 # 노드 높이 + self.left: TreeNode | None = None # 왼쪽 자식 노드 참조 + self.right: TreeNode | None = None # 오른쪽 자식 노드 참조 + ``` + +=== "C++" + + ```cpp title="" + /* AVL 트리 노드 클래스 */ + struct TreeNode { + int val{}; // 노드 값 + int height = 0; // 노드 높이 + TreeNode *left{}; // 왼쪽 자식 노드 + TreeNode *right{}; // 오른쪽 자식 노드 + TreeNode() = default; + explicit TreeNode(int x) : val(x){} + }; + ``` + +=== "Java" + + ```java title="" + /* AVL 트리 노드 클래스 */ + class TreeNode { + public int val; // 노드 값 + public int height; // 노드 높이 + public TreeNode left; // 왼쪽 자식 노드 + public TreeNode right; // 오른쪽 자식 노드 + public TreeNode(int x) { val = x; } + } + ``` + +=== "C#" + + ```csharp title="" + /* AVL 트리 노드 클래스 */ + class TreeNode(int? x) { + public int? val = x; // 노드 값 + public int height; // 노드 높이 + public TreeNode? left; // 왼쪽 자식 노드 참조 + public TreeNode? right; // 오른쪽 자식 노드 참조 + } + ``` + +=== "Go" + + ```go title="" + /* AVL 트리 노드 구조체 */ + type TreeNode struct { + Val int // 노드 값 + Height int // 노드 높이 + Left *TreeNode // 왼쪽 자식 노드 참조 + Right *TreeNode // 오른쪽 자식 노드 참조 + } + ``` + +=== "Swift" + + ```swift title="" + /* AVL 트리 노드 클래스 */ + class TreeNode { + var val: Int // 노드 값 + var height: Int // 노드 높이 + var left: TreeNode? // 왼쪽 자식 노드 + var right: TreeNode? // 오른쪽 자식 노드 + + init(x: Int) { + val = x + height = 0 + } + } + ``` + +=== "JS" + + ```javascript title="" + /* AVL 트리 노드 클래스 */ + class TreeNode { + val; // 노드 값 + height; //노드 높이 + left; // 왼쪽 자식 노드 포인터 + right; // 오른쪽 자식 노드 포인터 + constructor(val, left, right, height) { + this.val = val === undefined ? 0 : val; + this.height = height === undefined ? 0 : height; + this.left = left === undefined ? null : left; + this.right = right === undefined ? null : right; + } + } + ``` + +=== "TS" + + ```typescript title="" + /* AVL 트리 노드 클래스 */ + class TreeNode { + val: number; // 노드 값 + height: number; // 노드 높이 + left: TreeNode | null; // 왼쪽 자식 노드 포인터 + right: TreeNode | null; // 오른쪽 자식 노드 포인터 + constructor(val?: number, height?: number, left?: TreeNode | null, right?: TreeNode | null) { + this.val = val === undefined ? 0 : val; + this.height = height === undefined ? 0 : height; + this.left = left === undefined ? null : left; + this.right = right === undefined ? null : right; + } + } + ``` + +=== "Dart" + + ```dart title="" + /* AVL 트리 노드 클래스 */ + class TreeNode { + int val; // 노드 값 + int height; // 노드 높이 + TreeNode? left; // 왼쪽 자식 노드 + TreeNode? right; // 오른쪽 자식 노드 + TreeNode(this.val, [this.height = 0, this.left, this.right]); + } + ``` + +=== "Rust" + + ```rust title="" + use std::rc::Rc; + use std::cell::RefCell; + + /* AVL 트리 노드 구조체 */ + struct TreeNode { + val: i32, // 노드 값 + height: i32, // 노드 높이 + left: Option>>, // 왼쪽 자식 노드 + right: Option>>, // 오른쪽 자식 노드 + } + + impl TreeNode { + /* 생성자 */ + fn new(val: i32) -> Rc> { + Rc::new(RefCell::new(Self { + val, + height: 0, + left: None, + right: None + })) + } + } + ``` + +=== "C" + + ```c title="" + /* AVL 트리 노드 구조체 */ + typedef struct TreeNode { + int val; + int height; + struct TreeNode *left; + struct TreeNode *right; + } TreeNode; + + /* 생성자 */ + TreeNode *newTreeNode(int val) { + TreeNode *node; + + node = (TreeNode *)malloc(sizeof(TreeNode)); + node->val = val; + node->height = 0; + node->left = NULL; + node->right = NULL; + return node; + } + ``` + +=== "Kotlin" + + ```kotlin title="" + /* AVL 트리 노드 클래스 */ + class TreeNode(val _val: Int) { // 노드 값 + var height: Int = 0 // 노드 높이 + var left: TreeNode? = null // 왼쪽 자식 노드 + var right: TreeNode? = null // 오른쪽 자식 노드 + } + ``` + +=== "Ruby" + + ```ruby title="" + ### AVL 트리 노드 클래스 ### + class TreeNode + attr_accessor :val # 노드 값 + attr_accessor :height # 노드 높이 + attr_accessor :left # 왼쪽 자식 노드 참조 + attr_accessor :right # 오른쪽 자식 노드 참조 + + def initialize(val) + @val = val + @height = 0 + end + end + ``` + +'노드 높이'는 해당 노드에서 가장 먼 리프까지의 거리, 즉 거치는 '간선' 수다. 특히 리프의 높이는 $0$, 빈 노드의 높이는 $-1$임에 주의하자. 높이를 조회하고 갱신하는 두 보조 함수를 만든다. + +=== "Python" + + ```python title="avl_tree.py" + def height(self, node: TreeNode | None) -> int: + """노드 높이 반환""" + # 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + if node is not None: + return node.height + return -1 + + def update_height(self, node: TreeNode | None): + """노드 높이를 갱신한다""" + # 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node.height = max([self.height(node.left), self.height(node.right)]) + 1 + ``` + +=== "C++" + + ```cpp title="avl_tree.cpp" + /* 노드 높이 반환 */ + int height(TreeNode *node) { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + return node == nullptr ? -1 : node->height; + } + + /* 노드 높이를 갱신한다 */ + void updateHeight(TreeNode *node) { + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node->height = max(height(node->left), height(node->right)) + 1; + } + ``` + +=== "Java" + + ```java title="avl_tree.java" + /* 노드 높이 반환 */ + int height(TreeNode node) { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + return node == null ? -1 : node.height; + } + + /* 노드 높이를 갱신한다 */ + void updateHeight(TreeNode node) { + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node.height = Math.max(height(node.left), height(node.right)) + 1; + } + ``` + +=== "C#" + + ```csharp title="avl_tree.cs" + /* 노드 높이 반환 */ + int Height(TreeNode? node) { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + return node == null ? -1 : node.height; + } + + /* 노드 높이를 갱신한다 */ + void UpdateHeight(TreeNode node) { + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node.height = Math.Max(Height(node.left), Height(node.right)) + 1; + } + ``` + +=== "Go" + + ```go title="avl_tree.go" + /* 노드 높이 반환 */ + func (t *aVLTree) height(node *TreeNode) int { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + if node != nil { + return node.Height + } + return -1 + } + + /* 노드 높이를 갱신한다 */ + func (t *aVLTree) updateHeight(node *TreeNode) { + lh := t.height(node.Left) + rh := t.height(node.Right) + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + if lh > rh { + node.Height = lh + 1 + } else { + node.Height = rh + 1 + } + } + ``` + +=== "Swift" + + ```swift title="avl_tree.swift" + /* 노드 높이 반환 */ + func height(node: TreeNode?) -> Int { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + node?.height ?? -1 + } + + /* 노드 높이를 갱신한다 */ + func updateHeight(node: TreeNode?) { + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node?.height = max(height(node: node?.left), height(node: node?.right)) + 1 + } + ``` + +=== "JS" + + ```javascript title="avl_tree.js" + /* 노드 높이 반환 */ + height(node) { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + return node === null ? -1 : node.height; + } + + /* 노드 높이를 갱신한다 */ + #updateHeight(node) { + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node.height = + Math.max(this.height(node.left), this.height(node.right)) + 1; + } + ``` + +=== "TS" + + ```typescript title="avl_tree.ts" + /* 노드 높이 반환 */ + height(node: TreeNode): number { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + return node === null ? -1 : node.height; + } + + /* 노드 높이를 갱신한다 */ + updateHeight(node: TreeNode): void { + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node.height = + Math.max(this.height(node.left), this.height(node.right)) + 1; + } + ``` + +=== "Dart" + + ```dart title="avl_tree.dart" + /* 노드 높이 반환 */ + int height(TreeNode? node) { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + return node == null ? -1 : node.height; + } + + /* 노드 높이를 갱신한다 */ + void updateHeight(TreeNode? node) { + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node!.height = max(height(node.left), height(node.right)) + 1; + } + ``` + +=== "Rust" + + ```rust title="avl_tree.rs" + /* 노드 높이 반환 */ + fn height(node: OptionTreeNodeRc) -> i32 { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + match node { + Some(node) => node.borrow().height, + None => -1, + } + } + + /* 노드 높이를 갱신한다 */ + fn update_height(node: OptionTreeNodeRc) { + if let Some(node) = node { + let left = node.borrow().left.clone(); + let right = node.borrow().right.clone(); + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node.borrow_mut().height = std::cmp::max(Self::height(left), Self::height(right)) + 1; + } + } + ``` + +=== "C" + + ```c title="avl_tree.c" + /* 노드 높이 반환 */ + int height(TreeNode *node) { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + if (node != NULL) { + return node->height; + } + return -1; + } + + /* 노드 높이를 갱신한다 */ + void updateHeight(TreeNode *node) { + int lh = height(node->left); + int rh = height(node->right); + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + if (lh > rh) { + node->height = lh + 1; + } else { + node->height = rh + 1; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="avl_tree.kt" + /* 노드 높이 반환 */ + fun height(node: TreeNode?): Int { + // 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + return node?.height ?: -1 + } + + /* 노드 높이를 갱신한다 */ + fun updateHeight(node: TreeNode?) { + // 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node?.height = max(height(node?.left), height(node?.right)) + 1 + } + ``` + +=== "Ruby" + + ```ruby title="avl_tree.rb" + ### 노드 높이 반환 ### + def height(node) + # 빈 노드의 높이는 -1이고, 리프 노드의 높이는 0이다 + return node.height unless node.nil? + + -1 + end + + ### 노드 높이를 갱신한다 ### + def update_height(node) + # 노드 높이는 가장 높은 서브트리의 높이 + 1이다 + node.height = [height(node.left), height(node.right)].max + 1 + end + ``` + +### 2.   노드의 균형 인수 + +노드의 균형 인수(balance factor)는 왼쪽 서브트리 높이에서 오른쪽 서브트리 높이를 뺀 값이며, 빈 노드의 균형 인수는 $0$으로 정한다. 이후 편리하게 쓰도록 균형 인수를 구하는 기능도 함수로 만든다. + +=== "Python" + + ```python title="avl_tree.py" + def balance_factor(self, node: TreeNode | None) -> int: + """균형 인수 반환""" + # 빈 노드의 균형 인수는 0이다 + if node is None: + return 0 + # 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return self.height(node.left) - self.height(node.right) + ``` + +=== "C++" + + ```cpp title="avl_tree.cpp" + /* 균형 인수 반환 */ + int balanceFactor(TreeNode *node) { + // 빈 노드의 균형 인수는 0이다 + if (node == nullptr) + return 0; + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return height(node->left) - height(node->right); + } + ``` + +=== "Java" + + ```java title="avl_tree.java" + /* 균형 인수 반환 */ + int balanceFactor(TreeNode node) { + // 빈 노드의 균형 인수는 0이다 + if (node == null) + return 0; + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return height(node.left) - height(node.right); + } + ``` + +=== "C#" + + ```csharp title="avl_tree.cs" + /* 균형 인수 반환 */ + int BalanceFactor(TreeNode? node) { + // 빈 노드의 균형 인수는 0이다 + if (node == null) return 0; + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return Height(node.left) - Height(node.right); + } + ``` + +=== "Go" + + ```go title="avl_tree.go" + /* 균형 인수 반환 */ + func (t *aVLTree) balanceFactor(node *TreeNode) int { + // 빈 노드의 균형 인수는 0이다 + if node == nil { + return 0 + } + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return t.height(node.Left) - t.height(node.Right) + } + ``` + +=== "Swift" + + ```swift title="avl_tree.swift" + /* 균형 인수 반환 */ + func balanceFactor(node: TreeNode?) -> Int { + // 빈 노드의 균형 인수는 0이다 + guard let node = node else { return 0 } + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return height(node: node.left) - height(node: node.right) + } + ``` + +=== "JS" + + ```javascript title="avl_tree.js" + /* 균형 인수 반환 */ + balanceFactor(node) { + // 빈 노드의 균형 인수는 0이다 + if (node === null) return 0; + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return this.height(node.left) - this.height(node.right); + } + ``` + +=== "TS" + + ```typescript title="avl_tree.ts" + /* 균형 인수 반환 */ + balanceFactor(node: TreeNode): number { + // 빈 노드의 균형 인수는 0이다 + if (node === null) return 0; + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return this.height(node.left) - this.height(node.right); + } + ``` + +=== "Dart" + + ```dart title="avl_tree.dart" + /* 균형 인수 반환 */ + int balanceFactor(TreeNode? node) { + // 빈 노드의 균형 인수는 0이다 + if (node == null) return 0; + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return height(node.left) - height(node.right); + } + ``` + +=== "Rust" + + ```rust title="avl_tree.rs" + /* 균형 인수 반환 */ + fn balance_factor(node: OptionTreeNodeRc) -> i32 { + match node { + // 빈 노드의 균형 인수는 0이다 + None => 0, + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + Some(node) => { + Self::height(node.borrow().left.clone()) - Self::height(node.borrow().right.clone()) + } + } + } + ``` + +=== "C" + + ```c title="avl_tree.c" + /* 균형 인수 반환 */ + int balanceFactor(TreeNode *node) { + // 빈 노드의 균형 인수는 0이다 + if (node == NULL) { + return 0; + } + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return height(node->left) - height(node->right); + } + ``` + +=== "Kotlin" + + ```kotlin title="avl_tree.kt" + /* 균형 인수 반환 */ + fun balanceFactor(node: TreeNode?): Int { + // 빈 노드의 균형 인수는 0이다 + if (node == null) return 0 + // 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + return height(node.left) - height(node.right) + } + ``` + +=== "Ruby" + + ```ruby title="avl_tree.rb" + ### 균형 인수 반환 ### + def balance_factor(node) + # 빈 노드의 균형 인수는 0이다 + return 0 if node.nil? + + # 노드의 균형 인수 = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이 + height(node.left) - height(node.right) + end + ``` + +!!! tip "참고" + + 균형 인수를 $f$라고 하면 AVL 트리의 모든 노드는 $-1 \le f \le 1$을 만족한다. + +## 7.5.2   AVL 트리의 회전 + +AVL 트리의 특징은 '회전' 연산이다. 이진 트리의 중위 순회 결과를 바꾸지 않으면서 불균형 노드의 균형을 회복한다. 즉, **회전은 '이진 탐색 트리'의 성질을 유지하면서 트리를 다시 '균형 이진 트리'로 만든다**. + +균형 인수의 절댓값이 $> 1$인 노드를 '불균형 노드'라고 한다. 불균형 상황에 따라 오른쪽 회전, 왼쪽 회전, 오른쪽 후 왼쪽 회전, 왼쪽 후 오른쪽 회전의 네 종류로 나뉜다. 각각 자세히 알아보자. + +### 1.   오른쪽 회전 + +아래 그림(7-26)에서 노드 아래의 수는 균형 인수다. 아래에서 위로 볼 때 첫 불균형 노드는 '노드 3'이다. 이 노드를 루트로 하는 서브트리에 주목하여 불균형 노드를 `node`, 왼쪽 자식을 `child`라고 하고 '오른쪽 회전'을 수행한다. 회전 후 서브트리는 균형을 되찾으며 이진 탐색 트리의 성질도 유지한다. + +=== "<1>" + ![오른쪽 회전 단계](avl_tree.assets/avltree_right_rotate_step1.png){ class="animation-figure" } + +=== "<2>" + ![오른쪽 회전 2단계](avl_tree.assets/avltree_right_rotate_step2.png){ class="animation-figure" } + +=== "<3>" + ![오른쪽 회전 3단계](avl_tree.assets/avltree_right_rotate_step3.png){ class="animation-figure" } + +=== "<4>" + ![오른쪽 회전 4단계](avl_tree.assets/avltree_right_rotate_step4.png){ class="animation-figure" } + +

그림 7-26   오른쪽 회전 단계

+ +아래 그림(7-27)처럼 `child`에 오른쪽 자식 `grand_child`가 있으면, 오른쪽 회전 중 `grand_child`를 `node`의 왼쪽 자식으로 연결하는 단계를 추가해야 한다. + +![grand_child가 있는 오른쪽 회전](avl_tree.assets/avltree_right_rotate_with_grandchild.png){ class="animation-figure" } + +

그림 7-27   grand_child가 있는 오른쪽 회전

+ +'오른쪽으로 회전한다'는 시각적인 표현이며, 실제로는 노드 포인터를 수정하여 구현한다. 코드는 다음과 같다. + +=== "Python" + + ```python title="avl_tree.py" + def right_rotate(self, node: TreeNode | None) -> TreeNode | None: + """오른쪽 회전 연산""" + child = node.left + grand_child = child.right + # child를 중심으로 node를 오른쪽으로 회전한다 + child.right = node + node.left = grand_child + # 노드 높이를 갱신한다 + self.update_height(node) + self.update_height(child) + # 회전한 서브트리의 루트 노드를 반환한다 + return child + ``` + +=== "C++" + + ```cpp title="avl_tree.cpp" + /* 오른쪽 회전 연산 */ + TreeNode *rightRotate(TreeNode *node) { + TreeNode *child = node->left; + TreeNode *grandChild = child->right; + // child를 중심으로 node를 오른쪽으로 회전한다 + child->right = node; + node->left = grandChild; + // 노드 높이를 갱신한다 + updateHeight(node); + updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Java" + + ```java title="avl_tree.java" + /* 오른쪽 회전 연산 */ + TreeNode rightRotate(TreeNode node) { + TreeNode child = node.left; + TreeNode grandChild = child.right; + // child를 중심으로 node를 오른쪽으로 회전한다 + child.right = node; + node.left = grandChild; + // 노드 높이를 갱신한다 + updateHeight(node); + updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "C#" + + ```csharp title="avl_tree.cs" + /* 오른쪽 회전 연산 */ + TreeNode? RightRotate(TreeNode? node) { + TreeNode? child = node?.left; + TreeNode? grandChild = child?.right; + // child를 중심으로 node를 오른쪽으로 회전한다 + child.right = node; + node.left = grandChild; + // 노드 높이를 갱신한다 + UpdateHeight(node); + UpdateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Go" + + ```go title="avl_tree.go" + /* 오른쪽 회전 연산 */ + func (t *aVLTree) rightRotate(node *TreeNode) *TreeNode { + child := node.Left + grandChild := child.Right + // child를 중심으로 node를 오른쪽으로 회전한다 + child.Right = node + node.Left = grandChild + // 노드 높이를 갱신한다 + t.updateHeight(node) + t.updateHeight(child) + // 회전한 서브트리의 루트 노드를 반환한다 + return child + } + ``` + +=== "Swift" + + ```swift title="avl_tree.swift" + /* 오른쪽 회전 연산 */ + func rightRotate(node: TreeNode?) -> TreeNode? { + let child = node?.left + let grandChild = child?.right + // child를 중심으로 node를 오른쪽으로 회전한다 + child?.right = node + node?.left = grandChild + // 노드 높이를 갱신한다 + updateHeight(node: node) + updateHeight(node: child) + // 회전한 서브트리의 루트 노드를 반환한다 + return child + } + ``` + +=== "JS" + + ```javascript title="avl_tree.js" + /* 오른쪽 회전 연산 */ + #rightRotate(node) { + const child = node.left; + const grandChild = child.right; + // child를 중심으로 node를 오른쪽으로 회전한다 + child.right = node; + node.left = grandChild; + // 노드 높이를 갱신한다 + this.#updateHeight(node); + this.#updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "TS" + + ```typescript title="avl_tree.ts" + /* 오른쪽 회전 연산 */ + rightRotate(node: TreeNode): TreeNode { + const child = node.left; + const grandChild = child.right; + // child를 중심으로 node를 오른쪽으로 회전한다 + child.right = node; + node.left = grandChild; + // 노드 높이를 갱신한다 + this.updateHeight(node); + this.updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Dart" + + ```dart title="avl_tree.dart" + /* 오른쪽 회전 연산 */ + TreeNode? rightRotate(TreeNode? node) { + TreeNode? child = node!.left; + TreeNode? grandChild = child!.right; + // child를 중심으로 node를 오른쪽으로 회전한다 + child.right = node; + node.left = grandChild; + // 노드 높이를 갱신한다 + updateHeight(node); + updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Rust" + + ```rust title="avl_tree.rs" + /* 오른쪽 회전 연산 */ + fn right_rotate(node: OptionTreeNodeRc) -> OptionTreeNodeRc { + match node { + Some(node) => { + let child = node.borrow().left.clone().unwrap(); + let grand_child = child.borrow().right.clone(); + // child를 중심으로 node를 오른쪽으로 회전한다 + child.borrow_mut().right = Some(node.clone()); + node.borrow_mut().left = grand_child; + // 노드 높이를 갱신한다 + Self::update_height(Some(node)); + Self::update_height(Some(child.clone())); + // 회전한 서브트리의 루트 노드를 반환한다 + Some(child) + } + None => None, + } + } + ``` + +=== "C" + + ```c title="avl_tree.c" + /* 오른쪽 회전 연산 */ + TreeNode *rightRotate(TreeNode *node) { + TreeNode *child, *grandChild; + child = node->left; + grandChild = child->right; + // child를 중심으로 node를 오른쪽으로 회전한다 + child->right = node; + node->left = grandChild; + // 노드 높이를 갱신한다 + updateHeight(node); + updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Kotlin" + + ```kotlin title="avl_tree.kt" + /* 오른쪽 회전 연산 */ + fun rightRotate(node: TreeNode?): TreeNode { + val child = node!!.left + val grandChild = child!!.right + // child를 중심으로 node를 오른쪽으로 회전한다 + child.right = node + node.left = grandChild + // 노드 높이를 갱신한다 + updateHeight(node) + updateHeight(child) + // 회전한 서브트리의 루트 노드를 반환한다 + return child + } + ``` + +=== "Ruby" + + ```ruby title="avl_tree.rb" + ### 오른쪽 회전 연산 ### + def right_rotate(node) + child = node.left + grand_child = child.right + # child를 중심으로 node를 오른쪽으로 회전한다 + child.right = node + node.left = grand_child + # 노드 높이를 갱신한다 + update_height(node) + update_height(child) + # 회전한 서브트리의 루트 노드를 반환한다 + child + end + ``` + +### 2.   왼쪽 회전 + +앞의 불균형 이진 트리를 '거울에 비춘' 경우에는 아래 그림(7-28)과 같이 '왼쪽 회전'이 필요하다. + +![왼쪽 회전](avl_tree.assets/avltree_left_rotate.png){ class="animation-figure" } + +

그림 7-28   왼쪽 회전

+ +마찬가지로 아래 그림(7-29)처럼 `child`에 왼쪽 자식 `grand_child`가 있으면, 왼쪽 회전 중 `grand_child`를 `node`의 오른쪽 자식으로 연결하는 단계를 추가해야 한다. + +![grand_child가 있는 왼쪽 회전](avl_tree.assets/avltree_left_rotate_with_grandchild.png){ class="animation-figure" } + +

그림 7-29   grand_child가 있는 왼쪽 회전

+ +**오른쪽 회전과 왼쪽 회전은 논리적으로 거울 대칭이며, 각각 해결하는 불균형 상황도 대칭이다**. 따라서 오른쪽 회전 코드의 모든 `left`를 `right`로, 모든 `right`를 `left`로 바꾸면 왼쪽 회전 코드를 얻는다. + +=== "Python" + + ```python title="avl_tree.py" + def left_rotate(self, node: TreeNode | None) -> TreeNode | None: + """왼쪽 회전 연산""" + child = node.right + grand_child = child.left + # child를 중심으로 node를 왼쪽으로 회전한다 + child.left = node + node.right = grand_child + # 노드 높이를 갱신한다 + self.update_height(node) + self.update_height(child) + # 회전한 서브트리의 루트 노드를 반환한다 + return child + ``` + +=== "C++" + + ```cpp title="avl_tree.cpp" + /* 왼쪽 회전 연산 */ + TreeNode *leftRotate(TreeNode *node) { + TreeNode *child = node->right; + TreeNode *grandChild = child->left; + // child를 중심으로 node를 왼쪽으로 회전한다 + child->left = node; + node->right = grandChild; + // 노드 높이를 갱신한다 + updateHeight(node); + updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Java" + + ```java title="avl_tree.java" + /* 왼쪽 회전 연산 */ + TreeNode leftRotate(TreeNode node) { + TreeNode child = node.right; + TreeNode grandChild = child.left; + // child를 중심으로 node를 왼쪽으로 회전한다 + child.left = node; + node.right = grandChild; + // 노드 높이를 갱신한다 + updateHeight(node); + updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "C#" + + ```csharp title="avl_tree.cs" + /* 왼쪽 회전 연산 */ + TreeNode? LeftRotate(TreeNode? node) { + TreeNode? child = node?.right; + TreeNode? grandChild = child?.left; + // child를 중심으로 node를 왼쪽으로 회전한다 + child.left = node; + node.right = grandChild; + // 노드 높이를 갱신한다 + UpdateHeight(node); + UpdateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Go" + + ```go title="avl_tree.go" + /* 왼쪽 회전 연산 */ + func (t *aVLTree) leftRotate(node *TreeNode) *TreeNode { + child := node.Right + grandChild := child.Left + // child를 중심으로 node를 왼쪽으로 회전한다 + child.Left = node + node.Right = grandChild + // 노드 높이를 갱신한다 + t.updateHeight(node) + t.updateHeight(child) + // 회전한 서브트리의 루트 노드를 반환한다 + return child + } + ``` + +=== "Swift" + + ```swift title="avl_tree.swift" + /* 왼쪽 회전 연산 */ + func leftRotate(node: TreeNode?) -> TreeNode? { + let child = node?.right + let grandChild = child?.left + // child를 중심으로 node를 왼쪽으로 회전한다 + child?.left = node + node?.right = grandChild + // 노드 높이를 갱신한다 + updateHeight(node: node) + updateHeight(node: child) + // 회전한 서브트리의 루트 노드를 반환한다 + return child + } + ``` + +=== "JS" + + ```javascript title="avl_tree.js" + /* 왼쪽 회전 연산 */ + #leftRotate(node) { + const child = node.right; + const grandChild = child.left; + // child를 중심으로 node를 왼쪽으로 회전한다 + child.left = node; + node.right = grandChild; + // 노드 높이를 갱신한다 + this.#updateHeight(node); + this.#updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "TS" + + ```typescript title="avl_tree.ts" + /* 왼쪽 회전 연산 */ + leftRotate(node: TreeNode): TreeNode { + const child = node.right; + const grandChild = child.left; + // child를 중심으로 node를 왼쪽으로 회전한다 + child.left = node; + node.right = grandChild; + // 노드 높이를 갱신한다 + this.updateHeight(node); + this.updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Dart" + + ```dart title="avl_tree.dart" + /* 왼쪽 회전 연산 */ + TreeNode? leftRotate(TreeNode? node) { + TreeNode? child = node!.right; + TreeNode? grandChild = child!.left; + // child를 중심으로 node를 왼쪽으로 회전한다 + child.left = node; + node.right = grandChild; + // 노드 높이를 갱신한다 + updateHeight(node); + updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Rust" + + ```rust title="avl_tree.rs" + /* 왼쪽 회전 연산 */ + fn left_rotate(node: OptionTreeNodeRc) -> OptionTreeNodeRc { + match node { + Some(node) => { + let child = node.borrow().right.clone().unwrap(); + let grand_child = child.borrow().left.clone(); + // child를 중심으로 node를 왼쪽으로 회전한다 + child.borrow_mut().left = Some(node.clone()); + node.borrow_mut().right = grand_child; + // 노드 높이를 갱신한다 + Self::update_height(Some(node)); + Self::update_height(Some(child.clone())); + // 회전한 서브트리의 루트 노드를 반환한다 + Some(child) + } + None => None, + } + } + ``` + +=== "C" + + ```c title="avl_tree.c" + /* 왼쪽 회전 연산 */ + TreeNode *leftRotate(TreeNode *node) { + TreeNode *child, *grandChild; + child = node->right; + grandChild = child->left; + // child를 중심으로 node를 왼쪽으로 회전한다 + child->left = node; + node->right = grandChild; + // 노드 높이를 갱신한다 + updateHeight(node); + updateHeight(child); + // 회전한 서브트리의 루트 노드를 반환한다 + return child; + } + ``` + +=== "Kotlin" + + ```kotlin title="avl_tree.kt" + /* 왼쪽 회전 연산 */ + fun leftRotate(node: TreeNode?): TreeNode { + val child = node!!.right + val grandChild = child!!.left + // child를 중심으로 node를 왼쪽으로 회전한다 + child.left = node + node.right = grandChild + // 노드 높이를 갱신한다 + updateHeight(node) + updateHeight(child) + // 회전한 서브트리의 루트 노드를 반환한다 + return child + } + ``` + +=== "Ruby" + + ```ruby title="avl_tree.rb" + ### 왼쪽 회전 연산 ### + def left_rotate(node) + child = node.right + grand_child = child.left + # child를 중심으로 node를 왼쪽으로 회전한다 + child.left = node + node.right = grand_child + # 노드 높이를 갱신한다 + update_height(node) + update_height(child) + # 회전한 서브트리의 루트 노드를 반환한다 + child + end + ``` + +### 3.   왼쪽 회전 후 오른쪽 회전 + +아래 그림(7-30)의 불균형 노드 3은 왼쪽 회전이나 오른쪽 회전 한 번만으로 균형을 회복할 수 없다. 먼저 `child`를 '왼쪽 회전'한 뒤 `node`를 '오른쪽 회전'해야 한다. + +![왼쪽 회전 후 오른쪽 회전](avl_tree.assets/avltree_left_right_rotate.png){ class="animation-figure" } + +

그림 7-30   왼쪽 회전 후 오른쪽 회전

+ +### 4.   오른쪽 회전 후 왼쪽 회전 + +아래 그림(7-31)처럼 앞 상황의 거울 대칭인 경우에는 먼저 `child`를 '오른쪽 회전'한 뒤 `node`를 '왼쪽 회전'해야 한다. + +![오른쪽 회전 후 왼쪽 회전](avl_tree.assets/avltree_right_left_rotate.png){ class="animation-figure" } + +

그림 7-31   오른쪽 회전 후 왼쪽 회전

+ +### 5.   회전 선택 + +아래 그림(7-32)의 네 불균형 상황은 앞의 예시들과 각각 대응한다. 순서대로 오른쪽 회전, 왼쪽 후 오른쪽 회전, 오른쪽 후 왼쪽 회전, 왼쪽 회전이 필요하다. + +![AVL 트리의 네 가지 회전 상황](avl_tree.assets/avltree_rotation_cases.png){ class="animation-figure" } + +

그림 7-32   AVL 트리의 네 가지 회전 상황

+ +아래 표(7-3)처럼 불균형 노드의 균형 인수와 더 높은 쪽 자식의 균형 인수 부호를 확인하여 어느 상황에 해당하는지 판단한다. + +

표 7-3   네 가지 회전의 선택 조건

+ +
+ +| 불균형 노드의 균형 인수 | 자식 노드의 균형 인수 | 사용할 회전 | +| ------------------ | ---------------- | ---------------- | +| $> 1$ (왼쪽으로 치우친 트리) | $\geq 0$ | 오른쪽 회전 | +| $> 1$ (왼쪽으로 치우친 트리) | $<0$ | 왼쪽 회전 후 오른쪽 회전 | +| $< -1$ (오른쪽으로 치우친 트리) | $\leq 0$ | 왼쪽 회전 | +| $< -1$ (오른쪽으로 치우친 트리) | $>0$ | 오른쪽 회전 후 왼쪽 회전 | + +
+ +편리하게 사용하도록 회전 연산을 함수 하나로 묶는다. **이 함수를 사용하면 여러 불균형 상황에 맞게 회전하여 노드의 균형을 복구할 수 있다**. 코드는 다음과 같다. + +=== "Python" + + ```python title="avl_tree.py" + def rotate(self, node: TreeNode | None) -> TreeNode | None: + """회전 연산을 수행하여 해당 서브트리의 균형 복원""" + # 노드 node의 균형 인수를 구한다 + balance_factor = self.balance_factor(node) + # 왼쪽으로 치우친 트리 + if balance_factor > 1: + if self.balance_factor(node.left) >= 0: + # 오른쪽 회전 + return self.right_rotate(node) + else: + # 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node.left = self.left_rotate(node.left) + return self.right_rotate(node) + # 오른쪽으로 치우친 트리 + elif balance_factor < -1: + if self.balance_factor(node.right) <= 0: + # 왼쪽 회전 + return self.left_rotate(node) + else: + # 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node.right = self.right_rotate(node.right) + return self.left_rotate(node) + # 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node + ``` + +=== "C++" + + ```cpp title="avl_tree.cpp" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + TreeNode *rotate(TreeNode *node) { + // 노드 node의 균형 인수를 구한다 + int _balanceFactor = balanceFactor(node); + // 왼쪽으로 치우친 트리 + if (_balanceFactor > 1) { + if (balanceFactor(node->left) >= 0) { + // 오른쪽 회전 + return rightRotate(node); + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node->left = leftRotate(node->left); + return rightRotate(node); + } + } + // 오른쪽으로 치우친 트리 + if (_balanceFactor < -1) { + if (balanceFactor(node->right) <= 0) { + // 왼쪽 회전 + return leftRotate(node); + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node->right = rightRotate(node->right); + return leftRotate(node); + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node; + } + ``` + +=== "Java" + + ```java title="avl_tree.java" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + TreeNode rotate(TreeNode node) { + // 노드 node의 균형 인수를 구한다 + int balanceFactor = balanceFactor(node); + // 왼쪽으로 치우친 트리 + if (balanceFactor > 1) { + if (balanceFactor(node.left) >= 0) { + // 오른쪽 회전 + return rightRotate(node); + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node.left = leftRotate(node.left); + return rightRotate(node); + } + } + // 오른쪽으로 치우친 트리 + if (balanceFactor < -1) { + if (balanceFactor(node.right) <= 0) { + // 왼쪽 회전 + return leftRotate(node); + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node.right = rightRotate(node.right); + return leftRotate(node); + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node; + } + ``` + +=== "C#" + + ```csharp title="avl_tree.cs" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + TreeNode? Rotate(TreeNode? node) { + // 노드 node의 균형 인수를 구한다 + int balanceFactorInt = BalanceFactor(node); + // 왼쪽으로 치우친 트리 + if (balanceFactorInt > 1) { + if (BalanceFactor(node?.left) >= 0) { + // 오른쪽 회전 + return RightRotate(node); + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node!.left = LeftRotate(node!.left); + return RightRotate(node); + } + } + // 오른쪽으로 치우친 트리 + if (balanceFactorInt < -1) { + if (BalanceFactor(node?.right) <= 0) { + // 왼쪽 회전 + return LeftRotate(node); + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node!.right = RightRotate(node!.right); + return LeftRotate(node); + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node; + } + ``` + +=== "Go" + + ```go title="avl_tree.go" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + func (t *aVLTree) rotate(node *TreeNode) *TreeNode { + // 노드 node의 균형 인수를 구한다 + // Go에서는 짧은 변수명을 권장한다. 여기서 bf는 t.balanceFactor를 나타낸다 + bf := t.balanceFactor(node) + // 왼쪽으로 치우친 트리 + if bf > 1 { + if t.balanceFactor(node.Left) >= 0 { + // 오른쪽 회전 + return t.rightRotate(node) + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node.Left = t.leftRotate(node.Left) + return t.rightRotate(node) + } + } + // 오른쪽으로 치우친 트리 + if bf < -1 { + if t.balanceFactor(node.Right) <= 0 { + // 왼쪽 회전 + return t.leftRotate(node) + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node.Right = t.rightRotate(node.Right) + return t.leftRotate(node) + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node + } + ``` + +=== "Swift" + + ```swift title="avl_tree.swift" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + func rotate(node: TreeNode?) -> TreeNode? { + // 노드 node의 균형 인수를 구한다 + let balanceFactor = balanceFactor(node: node) + // 왼쪽으로 치우친 트리 + if balanceFactor > 1 { + if self.balanceFactor(node: node?.left) >= 0 { + // 오른쪽 회전 + return rightRotate(node: node) + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node?.left = leftRotate(node: node?.left) + return rightRotate(node: node) + } + } + // 오른쪽으로 치우친 트리 + if balanceFactor < -1 { + if self.balanceFactor(node: node?.right) <= 0 { + // 왼쪽 회전 + return leftRotate(node: node) + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node?.right = rightRotate(node: node?.right) + return leftRotate(node: node) + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node + } + ``` + +=== "JS" + + ```javascript title="avl_tree.js" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + #rotate(node) { + // 노드 node의 균형 인수를 구한다 + const balanceFactor = this.balanceFactor(node); + // 왼쪽으로 치우친 트리 + if (balanceFactor > 1) { + if (this.balanceFactor(node.left) >= 0) { + // 오른쪽 회전 + return this.#rightRotate(node); + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node.left = this.#leftRotate(node.left); + return this.#rightRotate(node); + } + } + // 오른쪽으로 치우친 트리 + if (balanceFactor < -1) { + if (this.balanceFactor(node.right) <= 0) { + // 왼쪽 회전 + return this.#leftRotate(node); + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node.right = this.#rightRotate(node.right); + return this.#leftRotate(node); + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node; + } + ``` + +=== "TS" + + ```typescript title="avl_tree.ts" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + rotate(node: TreeNode): TreeNode { + // 노드 node의 균형 인수를 구한다 + const balanceFactor = this.balanceFactor(node); + // 왼쪽으로 치우친 트리 + if (balanceFactor > 1) { + if (this.balanceFactor(node.left) >= 0) { + // 오른쪽 회전 + return this.rightRotate(node); + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node.left = this.leftRotate(node.left); + return this.rightRotate(node); + } + } + // 오른쪽으로 치우친 트리 + if (balanceFactor < -1) { + if (this.balanceFactor(node.right) <= 0) { + // 왼쪽 회전 + return this.leftRotate(node); + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node.right = this.rightRotate(node.right); + return this.leftRotate(node); + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node; + } + ``` + +=== "Dart" + + ```dart title="avl_tree.dart" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + TreeNode? rotate(TreeNode? node) { + // 노드 node의 균형 인수를 구한다 + int factor = balanceFactor(node); + // 왼쪽으로 치우친 트리 + if (factor > 1) { + if (balanceFactor(node!.left) >= 0) { + // 오른쪽 회전 + return rightRotate(node); + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node.left = leftRotate(node.left); + return rightRotate(node); + } + } + // 오른쪽으로 치우친 트리 + if (factor < -1) { + if (balanceFactor(node!.right) <= 0) { + // 왼쪽 회전 + return leftRotate(node); + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node.right = rightRotate(node.right); + return leftRotate(node); + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node; + } + ``` + +=== "Rust" + + ```rust title="avl_tree.rs" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + fn rotate(node: OptionTreeNodeRc) -> OptionTreeNodeRc { + // 노드 node의 균형 인수를 구한다 + let balance_factor = Self::balance_factor(node.clone()); + // 왼쪽으로 치우친 트리 + if balance_factor > 1 { + let node = node.unwrap(); + if Self::balance_factor(node.borrow().left.clone()) >= 0 { + // 오른쪽 회전 + Self::right_rotate(Some(node)) + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + let left = node.borrow().left.clone(); + node.borrow_mut().left = Self::left_rotate(left); + Self::right_rotate(Some(node)) + } + } + // 오른쪽으로 치우친 트리 + else if balance_factor < -1 { + let node = node.unwrap(); + if Self::balance_factor(node.borrow().right.clone()) <= 0 { + // 왼쪽 회전 + Self::left_rotate(Some(node)) + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + let right = node.borrow().right.clone(); + node.borrow_mut().right = Self::right_rotate(right); + Self::left_rotate(Some(node)) + } + } else { + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + node + } + } + ``` + +=== "C" + + ```c title="avl_tree.c" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + TreeNode *rotate(TreeNode *node) { + // 노드 node의 균형 인수를 구한다 + int bf = balanceFactor(node); + // 왼쪽으로 치우친 트리 + if (bf > 1) { + if (balanceFactor(node->left) >= 0) { + // 오른쪽 회전 + return rightRotate(node); + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node->left = leftRotate(node->left); + return rightRotate(node); + } + } + // 오른쪽으로 치우친 트리 + if (bf < -1) { + if (balanceFactor(node->right) <= 0) { + // 왼쪽 회전 + return leftRotate(node); + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node->right = rightRotate(node->right); + return leftRotate(node); + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node; + } + ``` + +=== "Kotlin" + + ```kotlin title="avl_tree.kt" + /* 회전 연산을 수행하여 해당 서브트리의 균형 복원 */ + fun rotate(node: TreeNode): TreeNode { + // 노드 node의 균형 인수를 구한다 + val balanceFactor = balanceFactor(node) + // 왼쪽으로 치우친 트리 + if (balanceFactor > 1) { + if (balanceFactor(node.left) >= 0) { + // 오른쪽 회전 + return rightRotate(node) + } else { + // 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node.left = leftRotate(node.left) + return rightRotate(node) + } + } + // 오른쪽으로 치우친 트리 + if (balanceFactor < -1) { + if (balanceFactor(node.right) <= 0) { + // 왼쪽 회전 + return leftRotate(node) + } else { + // 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node.right = rightRotate(node.right) + return leftRotate(node) + } + } + // 균형 트리이므로 회전할 필요 없이 바로 반환한다 + return node + } + ``` + +=== "Ruby" + + ```ruby title="avl_tree.rb" + ### 회전 연산을 수행하여 해당 서브트리의 균형 복원 ### + def rotate(node) + # 노드 node의 균형 인수를 구한다 + balance_factor = balance_factor(node) + # 왼쪽으로 치우친 트리 + if balance_factor > 1 + if balance_factor(node.left) >= 0 + # 오른쪽 회전 + return right_rotate(node) + else + # 왼쪽으로 회전한 후 오른쪽으로 회전한다 + node.left = left_rotate(node.left) + return right_rotate(node) + end + # 오른쪽으로 치우친 트리 + elsif balance_factor < -1 + if balance_factor(node.right) <= 0 + # 왼쪽 회전 + return left_rotate(node) + else + # 오른쪽으로 회전한 후 왼쪽으로 회전한다 + node.right = right_rotate(node.right) + return left_rotate(node) + end + end + # 균형 트리이므로 회전할 필요 없이 바로 반환한다 + node + end + ``` + +## 7.5.3   AVL 트리의 주요 연산 + +### 1.   노드 삽입 + +AVL 트리의 삽입은 기본적으로 이진 탐색 트리와 비슷하다. 다만 삽입한 노드에서 루트까지의 경로에 불균형 노드가 여러 개 생길 수 있다는 차이가 있다. 따라서 **삽입한 노드부터 아래에서 위로 회전하여 모든 불균형 노드의 균형을 회복해야 한다**. 코드는 다음과 같다. + +=== "Python" + + ```python title="avl_tree.py" + def insert(self, val): + """노드 삽입""" + self._root = self.insert_helper(self._root, val) + + def insert_helper(self, node: TreeNode | None, val: int) -> TreeNode: + """노드의 재귀적 삽입(보조 메서드)""" + if node is None: + return TreeNode(val) + # 1. 삽입 위치를 찾아 노드를 삽입한다 + if val < node.val: + node.left = self.insert_helper(node.left, val) + elif val > node.val: + node.right = self.insert_helper(node.right, val) + else: + # 중복 노드는 삽입하지 않고 바로 반환한다 + return node + # 노드 높이를 갱신한다 + self.update_height(node) + # 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 + return self.rotate(node) + ``` + +=== "C++" + + ```cpp title="avl_tree.cpp" + /* 노드 삽입 */ + void insert(int val) { + root = insertHelper(root, val); + } + + /* 노드의 재귀적 삽입(보조 메서드) */ + TreeNode *insertHelper(TreeNode *node, int val) { + if (node == nullptr) + return new TreeNode(val); + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if (val < node->val) + node->left = insertHelper(node->left, val); + else if (val > node->val) + node->right = insertHelper(node->right, val); + else + return node; // 중복 노드는 삽입하지 않고 바로 반환한다 + updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Java" + + ```java title="avl_tree.java" + /* 노드 삽입 */ + void insert(int val) { + root = insertHelper(root, val); + } + + /* 노드의 재귀적 삽입(보조 메서드) */ + TreeNode insertHelper(TreeNode node, int val) { + if (node == null) + return new TreeNode(val); + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if (val < node.val) + node.left = insertHelper(node.left, val); + else if (val > node.val) + node.right = insertHelper(node.right, val); + else + return node; // 중복 노드는 삽입하지 않고 바로 반환한다 + updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "C#" + + ```csharp title="avl_tree.cs" + /* 노드 삽입 */ + void Insert(int val) { + root = InsertHelper(root, val); + } + + /* 노드의 재귀적 삽입(보조 메서드) */ + TreeNode? InsertHelper(TreeNode? node, int val) { + if (node == null) return new TreeNode(val); + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if (val < node.val) + node.left = InsertHelper(node.left, val); + else if (val > node.val) + node.right = InsertHelper(node.right, val); + else + return node; // 중복 노드는 삽입하지 않고 바로 반환한다 + UpdateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = Rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Go" + + ```go title="avl_tree.go" + /* 노드 삽입 */ + func (t *aVLTree) insert(val int) { + t.root = t.insertHelper(t.root, val) + } + + /* 노드의 재귀적 삽입(보조 함수) */ + func (t *aVLTree) insertHelper(node *TreeNode, val int) *TreeNode { + if node == nil { + return NewTreeNode(val) + } + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if val < node.Val.(int) { + node.Left = t.insertHelper(node.Left, val) + } else if val > node.Val.(int) { + node.Right = t.insertHelper(node.Right, val) + } else { + // 중복 노드는 삽입하지 않고 바로 반환한다 + return node + } + // 노드 높이를 갱신한다 + t.updateHeight(node) + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = t.rotate(node) + // 서브트리의 루트 노드를 반환한다 + return node + } + ``` + +=== "Swift" + + ```swift title="avl_tree.swift" + /* 노드 삽입 */ + func insert(val: Int) { + root = insertHelper(node: root, val: val) + } + + /* 노드의 재귀적 삽입(보조 메서드) */ + func insertHelper(node: TreeNode?, val: Int) -> TreeNode? { + var node = node + if node == nil { + return TreeNode(x: val) + } + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if val < node!.val { + node?.left = insertHelper(node: node?.left, val: val) + } else if val > node!.val { + node?.right = insertHelper(node: node?.right, val: val) + } else { + return node // 중복 노드는 삽입하지 않고 바로 반환한다 + } + updateHeight(node: node) // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node: node) + // 서브트리의 루트 노드를 반환한다 + return node + } + ``` + +=== "JS" + + ```javascript title="avl_tree.js" + /* 노드 삽입 */ + insert(val) { + this.root = this.#insertHelper(this.root, val); + } + + /* 노드의 재귀적 삽입(보조 메서드) */ + #insertHelper(node, val) { + if (node === null) return new TreeNode(val); + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if (val < node.val) node.left = this.#insertHelper(node.left, val); + else if (val > node.val) + node.right = this.#insertHelper(node.right, val); + else return node; // 중복 노드는 삽입하지 않고 바로 반환한다 + this.#updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = this.#rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "TS" + + ```typescript title="avl_tree.ts" + /* 노드 삽입 */ + insert(val: number): void { + this.root = this.insertHelper(this.root, val); + } + + /* 노드의 재귀적 삽입(보조 메서드) */ + insertHelper(node: TreeNode, val: number): TreeNode { + if (node === null) return new TreeNode(val); + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if (val < node.val) { + node.left = this.insertHelper(node.left, val); + } else if (val > node.val) { + node.right = this.insertHelper(node.right, val); + } else { + return node; // 중복 노드는 삽입하지 않고 바로 반환한다 + } + this.updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = this.rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Dart" + + ```dart title="avl_tree.dart" + /* 노드 삽입 */ + void insert(int val) { + root = insertHelper(root, val); + } + + /* 노드의 재귀적 삽입(보조 메서드) */ + TreeNode? insertHelper(TreeNode? node, int val) { + if (node == null) return TreeNode(val); + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if (val < node.val) + node.left = insertHelper(node.left, val); + else if (val > node.val) + node.right = insertHelper(node.right, val); + else + return node; // 중복 노드는 삽입하지 않고 바로 반환한다 + updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Rust" + + ```rust title="avl_tree.rs" + /* 노드 삽입 */ + fn insert(&mut self, val: i32) { + self.root = Self::insert_helper(self.root.clone(), val); + } + + /* 노드의 재귀적 삽입(보조 메서드) */ + fn insert_helper(node: OptionTreeNodeRc, val: i32) -> OptionTreeNodeRc { + match node { + Some(mut node) => { + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + match { + let node_val = node.borrow().val; + node_val + } + .cmp(&val) + { + Ordering::Greater => { + let left = node.borrow().left.clone(); + node.borrow_mut().left = Self::insert_helper(left, val); + } + Ordering::Less => { + let right = node.borrow().right.clone(); + node.borrow_mut().right = Self::insert_helper(right, val); + } + Ordering::Equal => { + return Some(node); // 중복 노드는 삽입하지 않고 바로 반환한다 + } + } + Self::update_height(Some(node.clone())); // 노드 높이를 갱신한다 + + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = Self::rotate(Some(node)).unwrap(); + // 서브트리의 루트 노드를 반환한다 + Some(node) + } + None => Some(TreeNode::new(val)), + } + } + ``` + +=== "C" + + ```c title="avl_tree.c" + /* 노드 삽입 */ + void insert(AVLTree *tree, int val) { + tree->root = insertHelper(tree->root, val); + } + + /* 노드의 재귀적 삽입(보조 함수) */ + TreeNode *insertHelper(TreeNode *node, int val) { + if (node == NULL) { + return newTreeNode(val); + } + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if (val < node->val) { + node->left = insertHelper(node->left, val); + } else if (val > node->val) { + node->right = insertHelper(node->right, val); + } else { + // 중복 노드는 삽입하지 않고 바로 반환한다 + return node; + } + // 노드 높이를 갱신한다 + updateHeight(node); + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Kotlin" + + ```kotlin title="avl_tree.kt" + /* 노드 삽입 */ + fun insert(_val: Int) { + root = insertHelper(root, _val) + } + + /* 노드의 재귀적 삽입(보조 메서드) */ + fun insertHelper(n: TreeNode?, _val: Int): TreeNode { + if (n == null) + return TreeNode(_val) + var node = n + /* 1. 삽입 위치를 찾아 노드를 삽입한다 */ + if (_val < node._val) + node.left = insertHelper(node.left, _val) + else if (_val > node._val) + node.right = insertHelper(node.right, _val) + else + return node // 중복 노드는 삽입하지 않고 바로 반환한다 + updateHeight(node) // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node) + // 서브트리의 루트 노드를 반환한다 + return node + } + ``` + +=== "Ruby" + + ```ruby title="avl_tree.rb" + ### 노드 삽입 ### + def insert(val) + @root = insert_helper(@root, val) + end + + ### 노드의 재귀적 삽입(보조 메서드)### + def insert_helper(node, val) + return TreeNode.new(val) if node.nil? + # 1. 삽입 위치를 찾아 노드를 삽입한다 + if val < node.val + node.left = insert_helper(node.left, val) + elsif val > node.val + node.right = insert_helper(node.right, val) + else + # 중복 노드는 삽입하지 않고 바로 반환한다 + return node + end + # 노드 높이를 갱신한다 + update_height(node) + # 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 + rotate(node) + end + ``` + +### 2.   노드 삭제 + +마찬가지로 이진 탐색 트리의 삭제 방법에 더하여 아래에서 위로 회전하며 모든 불균형 노드의 균형을 회복한다. 코드는 다음과 같다. + +=== "Python" + + ```python title="avl_tree.py" + def remove(self, val: int): + """노드 삭제""" + self._root = self.remove_helper(self._root, val) + + def remove_helper(self, node: TreeNode | None, val: int) -> TreeNode | None: + """노드의 재귀적 삭제(보조 메서드)""" + if node is None: + return None + # 1. 노드를 찾아 삭제한다 + if val < node.val: + node.left = self.remove_helper(node.left, val) + elif val > node.val: + node.right = self.remove_helper(node.right, val) + else: + if node.left is None or node.right is None: + child = node.left or node.right + # 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if child is None: + return None + # 자식 노드 수 = 1이면 node를 바로 삭제한다 + else: + node = child + else: + # 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + temp = node.right + while temp.left is not None: + temp = temp.left + node.right = self.remove_helper(node.right, temp.val) + node.val = temp.val + # 노드 높이를 갱신한다 + self.update_height(node) + # 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 + return self.rotate(node) + ``` + +=== "C++" + + ```cpp title="avl_tree.cpp" + /* 노드 삭제 */ + void remove(int val) { + root = removeHelper(root, val); + } + + /* 노드의 재귀적 삭제(보조 메서드) */ + TreeNode *removeHelper(TreeNode *node, int val) { + if (node == nullptr) + return nullptr; + /* 1. 노드를 찾아 삭제한다 */ + if (val < node->val) + node->left = removeHelper(node->left, val); + else if (val > node->val) + node->right = removeHelper(node->right, val); + else { + if (node->left == nullptr || node->right == nullptr) { + TreeNode *child = node->left != nullptr ? node->left : node->right; + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if (child == nullptr) { + delete node; + return nullptr; + } + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + else { + delete node; + node = child; + } + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + TreeNode *temp = node->right; + while (temp->left != nullptr) { + temp = temp->left; + } + int tempVal = temp->val; + node->right = removeHelper(node->right, temp->val); + node->val = tempVal; + } + } + updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Java" + + ```java title="avl_tree.java" + /* 노드 삭제 */ + void remove(int val) { + root = removeHelper(root, val); + } + + /* 노드의 재귀적 삭제(보조 메서드) */ + TreeNode removeHelper(TreeNode node, int val) { + if (node == null) + return null; + /* 1. 노드를 찾아 삭제한다 */ + if (val < node.val) + node.left = removeHelper(node.left, val); + else if (val > node.val) + node.right = removeHelper(node.right, val); + else { + if (node.left == null || node.right == null) { + TreeNode child = node.left != null ? node.left : node.right; + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if (child == null) + return null; + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + else + node = child; + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + TreeNode temp = node.right; + while (temp.left != null) { + temp = temp.left; + } + node.right = removeHelper(node.right, temp.val); + node.val = temp.val; + } + } + updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "C#" + + ```csharp title="avl_tree.cs" + /* 노드 삭제 */ + void Remove(int val) { + root = RemoveHelper(root, val); + } + + /* 노드의 재귀적 삭제(보조 메서드) */ + TreeNode? RemoveHelper(TreeNode? node, int val) { + if (node == null) return null; + /* 1. 노드를 찾아 삭제한다 */ + if (val < node.val) + node.left = RemoveHelper(node.left, val); + else if (val > node.val) + node.right = RemoveHelper(node.right, val); + else { + if (node.left == null || node.right == null) { + TreeNode? child = node.left ?? node.right; + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if (child == null) + return null; + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + else + node = child; + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + TreeNode? temp = node.right; + while (temp.left != null) { + temp = temp.left; + } + node.right = RemoveHelper(node.right, temp.val!.Value); + node.val = temp.val; + } + } + UpdateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = Rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Go" + + ```go title="avl_tree.go" + /* 노드 삭제 */ + func (t *aVLTree) remove(val int) { + t.root = t.removeHelper(t.root, val) + } + + /* 노드의 재귀적 삭제(보조 함수) */ + func (t *aVLTree) removeHelper(node *TreeNode, val int) *TreeNode { + if node == nil { + return nil + } + /* 1. 노드를 찾아 삭제한다 */ + if val < node.Val.(int) { + node.Left = t.removeHelper(node.Left, val) + } else if val > node.Val.(int) { + node.Right = t.removeHelper(node.Right, val) + } else { + if node.Left == nil || node.Right == nil { + child := node.Left + if node.Right != nil { + child = node.Right + } + if child == nil { + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + return nil + } else { + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + node = child + } + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + temp := node.Right + for temp.Left != nil { + temp = temp.Left + } + node.Right = t.removeHelper(node.Right, temp.Val.(int)) + node.Val = temp.Val + } + } + // 노드 높이를 갱신한다 + t.updateHeight(node) + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = t.rotate(node) + // 서브트리의 루트 노드를 반환한다 + return node + } + ``` + +=== "Swift" + + ```swift title="avl_tree.swift" + /* 노드 삭제 */ + func remove(val: Int) { + root = removeHelper(node: root, val: val) + } + + /* 노드의 재귀적 삭제(보조 메서드) */ + func removeHelper(node: TreeNode?, val: Int) -> TreeNode? { + var node = node + if node == nil { + return nil + } + /* 1. 노드를 찾아 삭제한다 */ + if val < node!.val { + node?.left = removeHelper(node: node?.left, val: val) + } else if val > node!.val { + node?.right = removeHelper(node: node?.right, val: val) + } else { + if node?.left == nil || node?.right == nil { + let child = node?.left ?? node?.right + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if child == nil { + return nil + } + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + else { + node = child + } + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + var temp = node?.right + while temp?.left != nil { + temp = temp?.left + } + node?.right = removeHelper(node: node?.right, val: temp!.val) + node?.val = temp!.val + } + } + updateHeight(node: node) // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node: node) + // 서브트리의 루트 노드를 반환한다 + return node + } + ``` + +=== "JS" + + ```javascript title="avl_tree.js" + /* 노드 삭제 */ + remove(val) { + this.root = this.#removeHelper(this.root, val); + } + + /* 노드의 재귀적 삭제(보조 메서드) */ + #removeHelper(node, val) { + if (node === null) return null; + /* 1. 노드를 찾아 삭제한다 */ + if (val < node.val) node.left = this.#removeHelper(node.left, val); + else if (val > node.val) + node.right = this.#removeHelper(node.right, val); + else { + if (node.left === null || node.right === null) { + const child = node.left !== null ? node.left : node.right; + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if (child === null) return null; + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + else node = child; + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + let temp = node.right; + while (temp.left !== null) { + temp = temp.left; + } + node.right = this.#removeHelper(node.right, temp.val); + node.val = temp.val; + } + } + this.#updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = this.#rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "TS" + + ```typescript title="avl_tree.ts" + /* 노드 삭제 */ + remove(val: number): void { + this.root = this.removeHelper(this.root, val); + } + + /* 노드의 재귀적 삭제(보조 메서드) */ + removeHelper(node: TreeNode, val: number): TreeNode { + if (node === null) return null; + /* 1. 노드를 찾아 삭제한다 */ + if (val < node.val) { + node.left = this.removeHelper(node.left, val); + } else if (val > node.val) { + node.right = this.removeHelper(node.right, val); + } else { + if (node.left === null || node.right === null) { + const child = node.left !== null ? node.left : node.right; + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if (child === null) { + return null; + } else { + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + node = child; + } + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + let temp = node.right; + while (temp.left !== null) { + temp = temp.left; + } + node.right = this.removeHelper(node.right, temp.val); + node.val = temp.val; + } + } + this.updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = this.rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Dart" + + ```dart title="avl_tree.dart" + /* 노드 삭제 */ + void remove(int val) { + root = removeHelper(root, val); + } + + /* 노드의 재귀적 삭제(보조 메서드) */ + TreeNode? removeHelper(TreeNode? node, int val) { + if (node == null) return null; + /* 1. 노드를 찾아 삭제한다 */ + if (val < node.val) + node.left = removeHelper(node.left, val); + else if (val > node.val) + node.right = removeHelper(node.right, val); + else { + if (node.left == null || node.right == null) { + TreeNode? child = node.left ?? node.right; + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if (child == null) + return null; + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + else + node = child; + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + TreeNode? temp = node.right; + while (temp!.left != null) { + temp = temp.left; + } + node.right = removeHelper(node.right, temp.val); + node.val = temp.val; + } + } + updateHeight(node); // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Rust" + + ```rust title="avl_tree.rs" + /* 노드 삭제 */ + fn remove(&self, val: i32) { + Self::remove_helper(self.root.clone(), val); + } + + /* 노드의 재귀적 삭제(보조 메서드) */ + fn remove_helper(node: OptionTreeNodeRc, val: i32) -> OptionTreeNodeRc { + match node { + Some(mut node) => { + /* 1. 노드를 찾아 삭제한다 */ + if val < node.borrow().val { + let left = node.borrow().left.clone(); + node.borrow_mut().left = Self::remove_helper(left, val); + } else if val > node.borrow().val { + let right = node.borrow().right.clone(); + node.borrow_mut().right = Self::remove_helper(right, val); + } else if node.borrow().left.is_none() || node.borrow().right.is_none() { + let child = if node.borrow().left.is_some() { + node.borrow().left.clone() + } else { + node.borrow().right.clone() + }; + match child { + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + None => { + return None; + } + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + Some(child) => node = child, + } + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + let mut temp = node.borrow().right.clone().unwrap(); + loop { + let temp_left = temp.borrow().left.clone(); + if temp_left.is_none() { + break; + } + temp = temp_left.unwrap(); + } + let right = node.borrow().right.clone(); + node.borrow_mut().right = Self::remove_helper(right, temp.borrow().val); + node.borrow_mut().val = temp.borrow().val; + } + Self::update_height(Some(node.clone())); // 노드 높이를 갱신한다 + + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = Self::rotate(Some(node)).unwrap(); + // 서브트리의 루트 노드를 반환한다 + Some(node) + } + None => None, + } + } + ``` + +=== "C" + + ```c title="avl_tree.c" + /* 노드 삭제 */ + // stdio.h를 포함했으므로 여기서는 remove라는 이름을 사용할 수 없다 + void removeItem(AVLTree *tree, int val) { + TreeNode *root = removeHelper(tree->root, val); + } + + /* 노드의 재귀적 삭제(보조 함수) */ + TreeNode *removeHelper(TreeNode *node, int val) { + TreeNode *child, *grandChild; + if (node == NULL) { + return NULL; + } + /* 1. 노드를 찾아 삭제한다 */ + if (val < node->val) { + node->left = removeHelper(node->left, val); + } else if (val > node->val) { + node->right = removeHelper(node->right, val); + } else { + if (node->left == NULL || node->right == NULL) { + child = node->left; + if (node->right != NULL) { + child = node->right; + } + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if (child == NULL) { + return NULL; + } else { + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + node = child; + } + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + TreeNode *temp = node->right; + while (temp->left != NULL) { + temp = temp->left; + } + int tempVal = temp->val; + node->right = removeHelper(node->right, temp->val); + node->val = tempVal; + } + } + // 노드 높이를 갱신한다 + updateHeight(node); + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node); + // 서브트리의 루트 노드를 반환한다 + return node; + } + ``` + +=== "Kotlin" + + ```kotlin title="avl_tree.kt" + /* 노드 삭제 */ + fun remove(_val: Int) { + root = removeHelper(root, _val) + } + + /* 노드의 재귀적 삭제(보조 메서드) */ + fun removeHelper(n: TreeNode?, _val: Int): TreeNode? { + var node = n ?: return null + /* 1. 노드를 찾아 삭제한다 */ + if (_val < node._val) + node.left = removeHelper(node.left, _val) + else if (_val > node._val) + node.right = removeHelper(node.right, _val) + else { + if (node.left == null || node.right == null) { + val child = if (node.left != null) + node.left + else + node.right + // 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + if (child == null) + return null + // 자식 노드 수 = 1이면 node를 바로 삭제한다 + else + node = child + } else { + // 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + var temp = node.right + while (temp!!.left != null) { + temp = temp.left + } + node.right = removeHelper(node.right, temp._val) + node._val = temp._val + } + } + updateHeight(node) // 노드 높이를 갱신한다 + /* 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 */ + node = rotate(node) + // 서브트리의 루트 노드를 반환한다 + return node + } + ``` + +=== "Ruby" + + ```ruby title="avl_tree.rb" + ### 노드 삭제 ### + def remove(val) + @root = remove_helper(@root, val) + end + + ### 노드의 재귀적 삭제(보조 메서드)### + def remove_helper(node, val) + return if node.nil? + # 1. 노드를 찾아 삭제한다 + if val < node.val + node.left = remove_helper(node.left, val) + elsif val > node.val + node.right = remove_helper(node.right, val) + else + if node.left.nil? || node.right.nil? + child = node.left || node.right + # 자식 노드 수 = 0이면 node를 바로 삭제하고 반환한다 + return if child.nil? + # 자식 노드 수 = 1이면 node를 바로 삭제한다 + node = child + else + # 자식 노드 수 = 2이면 중위 순회의 다음 노드를 삭제하고, 그 노드로 현재 노드를 대체한다 + temp = node.right + while !temp.left.nil? + temp = temp.left + end + node.right = remove_helper(node.right, temp.val) + node.val = temp.val + end + end + # 노드 높이를 갱신한다 + update_height(node) + # 2. 회전 연산을 수행하여 해당 서브트리의 균형을 복원한다 + rotate(node) + end + ``` + +### 3.   노드 탐색 + +AVL 트리의 노드 탐색은 이진 탐색 트리와 같으므로 다시 설명하지 않는다. + +## 7.5.4   AVL 트리의 대표적인 응용 + +- 대규모 데이터를 구성하고 저장하며, 탐색이 잦고 추가와 삭제가 드문 상황에 적합하다. +- 데이터베이스의 인덱스 시스템을 구축하는 데 사용한다. +- 레드-블랙 트리도 대표적인 균형 이진 탐색 트리다. AVL 트리보다 균형 조건이 느슨하여 삽입과 삭제에 필요한 회전 수가 적고, 노드 추가와 삭제의 평균 효율이 더 높다. diff --git a/ko/docs/chapter_tree/binary_search_tree.md b/ko/docs/chapter_tree/binary_search_tree.md new file mode 100644 index 000000000..bdc4be79f --- /dev/null +++ b/ko/docs/chapter_tree/binary_search_tree.md @@ -0,0 +1,1636 @@ +--- +comments: true +--- + +# 7.4   이진 탐색 트리 + +아래 그림(7-16)의 이진 탐색 트리(binary search tree)는 다음 조건을 만족한다. + +1. 루트에 대해 왼쪽 서브트리의 모든 노드 값 $<$ 루트 값 $<$ 오른쪽 서브트리의 모든 노드 값이다. +2. 임의의 노드의 왼쪽과 오른쪽 서브트리도 이진 탐색 트리로, 조건 `1.`을 만족한다. + +![이진 탐색 트리](binary_search_tree.assets/binary_search_tree.png){ class="animation-figure" } + +

그림 7-16   이진 탐색 트리

+ +## 7.4.1   이진 탐색 트리의 연산 + +이진 탐색 트리를 `BinarySearchTree` 클래스로 묶고, 루트 노드를 가리키는 멤버 변수 `root`를 정의한다. + +### 1.   노드 탐색 + +목표 노드의 값 `num`이 주어지면 이진 탐색 트리의 성질로 찾을 수 있다. 아래 그림(7-17)과 같이 노드 `cur`를 두고 루트 `root`에서 출발하여 `cur.val`과 `num`의 대소 관계를 반복해서 비교한다. + +- `cur.val < num`이면 목표 노드는 `cur`의 오른쪽 서브트리에 있으므로 `cur = cur.right`를 수행한다. +- `cur.val > num`이면 목표 노드는 `cur`의 왼쪽 서브트리에 있으므로 `cur = cur.left`를 수행한다. +- `cur.val = num`이면 목표 노드를 찾았으므로 반복문을 빠져나와 해당 노드를 반환한다. + +=== "<1>" + ![이진 탐색 트리의 노드 탐색 예시](binary_search_tree.assets/bst_search_step1.png){ class="animation-figure" } + +=== "<2>" + ![이진 탐색 트리 탐색 2단계](binary_search_tree.assets/bst_search_step2.png){ class="animation-figure" } + +=== "<3>" + ![이진 탐색 트리 탐색 3단계](binary_search_tree.assets/bst_search_step3.png){ class="animation-figure" } + +=== "<4>" + ![이진 탐색 트리 탐색 4단계](binary_search_tree.assets/bst_search_step4.png){ class="animation-figure" } + +

그림 7-17   이진 탐색 트리의 노드 탐색 예시

+ +이진 탐색 트리의 탐색 원리는 이진 탐색 알고리즘과 같으며, 매번 경우의 절반을 제외한다. 반복 횟수는 최대 트리 높이이므로 트리가 균형을 이루면 $O(\log n)$ 시간이 걸린다. 예시 코드는 다음과 같다. + +=== "Python" + + ```python title="binary_search_tree.py" + def search(self, num: int) -> TreeNode | None: + """노드 탐색""" + cur = self._root + # 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while cur is not None: + # 목표 노드는 cur의 오른쪽 서브트리에 있다 + if cur.val < num: + cur = cur.right + # 목표 노드는 cur의 왼쪽 서브트리에 있다 + elif cur.val > num: + cur = cur.left + # 목표 노드를 찾았으므로 반복문을 종료한다 + else: + break + return cur + ``` + +=== "C++" + + ```cpp title="binary_search_tree.cpp" + /* 노드 탐색 */ + TreeNode *search(int num) { + TreeNode *cur = root; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != nullptr) { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + if (cur->val < num) + cur = cur->right; + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + else if (cur->val > num) + cur = cur->left; + // 목표 노드를 찾았으므로 반복문을 종료한다 + else + break; + } + // 목표 노드를 반환한다 + return cur; + } + ``` + +=== "Java" + + ```java title="binary_search_tree.java" + /* 노드 탐색 */ + TreeNode search(int num) { + TreeNode cur = root; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) + cur = cur.right; + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + else if (cur.val > num) + cur = cur.left; + // 목표 노드를 찾았으므로 반복문을 종료한다 + else + break; + } + // 목표 노드를 반환한다 + return cur; + } + ``` + +=== "C#" + + ```csharp title="binary_search_tree.cs" + /* 노드 탐색 */ + TreeNode? Search(int num) { + TreeNode? cur = root; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) cur = + cur.right; + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + else if (cur.val > num) + cur = cur.left; + // 목표 노드를 찾았으므로 반복문을 종료한다 + else + break; + } + // 목표 노드를 반환한다 + return cur; + } + ``` + +=== "Go" + + ```go title="binary_search_tree.go" + /* 노드 탐색 */ + func (bst *binarySearchTree) search(num int) *TreeNode { + node := bst.root + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + for node != nil { + if node.Val.(int) < num { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + node = node.Right + } else if node.Val.(int) > num { + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + node = node.Left + } else { + // 목표 노드를 찾았으므로 반복문을 종료한다 + break + } + } + // 목표 노드를 반환한다 + return node + } + ``` + +=== "Swift" + + ```swift title="binary_search_tree.swift" + /* 노드 탐색 */ + func search(num: Int) -> TreeNode? { + var cur = root + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while cur != nil { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + if cur!.val < num { + cur = cur?.right + } + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + else if cur!.val > num { + cur = cur?.left + } + // 목표 노드를 찾았으므로 반복문을 종료한다 + else { + break + } + } + // 목표 노드를 반환한다 + return cur + } + ``` + +=== "JS" + + ```javascript title="binary_search_tree.js" + /* 노드 탐색 */ + search(num) { + let cur = this.root; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur !== null) { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) cur = cur.right; + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + else if (cur.val > num) cur = cur.left; + // 목표 노드를 찾았으므로 반복문을 종료한다 + else break; + } + // 목표 노드를 반환한다 + return cur; + } + ``` + +=== "TS" + + ```typescript title="binary_search_tree.ts" + /* 노드 탐색 */ + search(num: number): TreeNode | null { + let cur = this.root; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur !== null) { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) cur = cur.right; + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + else if (cur.val > num) cur = cur.left; + // 목표 노드를 찾았으므로 반복문을 종료한다 + else break; + } + // 목표 노드를 반환한다 + return cur; + } + ``` + +=== "Dart" + + ```dart title="binary_search_tree.dart" + /* 노드 탐색 */ + TreeNode? search(int _num) { + TreeNode? cur = _root; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < _num) + cur = cur.right; + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + else if (cur.val > _num) + cur = cur.left; + // 목표 노드를 찾았으므로 반복문을 종료한다 + else + break; + } + // 목표 노드를 반환한다 + return cur; + } + ``` + +=== "Rust" + + ```rust title="binary_search_tree.rs" + /* 노드 탐색 */ + pub fn search(&self, num: i32) -> OptionTreeNodeRc { + let mut cur = self.root.clone(); + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while let Some(node) = cur.clone() { + match num.cmp(&node.borrow().val) { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + Ordering::Greater => cur = node.borrow().right.clone(), + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + Ordering::Less => cur = node.borrow().left.clone(), + // 목표 노드를 찾았으므로 반복문을 종료한다 + Ordering::Equal => break, + } + } + + // 목표 노드를 반환한다 + cur + } + ``` + +=== "C" + + ```c title="binary_search_tree.c" + /* 노드 탐색 */ + TreeNode *search(BinarySearchTree *bst, int num) { + TreeNode *cur = bst->root; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != NULL) { + if (cur->val < num) { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + cur = cur->right; + } else if (cur->val > num) { + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + cur = cur->left; + } else { + // 목표 노드를 찾았으므로 반복문을 종료한다 + break; + } + } + // 목표 노드를 반환한다 + return cur; + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search_tree.kt" + /* 노드 탐색 */ + fun search(num: Int): TreeNode? { + var cur = root + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 목표 노드는 cur의 오른쪽 서브트리에 있다 + cur = if (cur._val < num) + cur.right + // 목표 노드는 cur의 왼쪽 서브트리에 있다 + else if (cur._val > num) + cur.left + // 목표 노드를 찾았으므로 반복문을 종료한다 + else + break + } + // 목표 노드를 반환한다 + return cur + } + ``` + +=== "Ruby" + + ```ruby title="binary_search_tree.rb" + ### 노드 탐색 ### + def search(num) + cur = @root + + # 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while !cur.nil? + # 목표 노드는 cur의 오른쪽 서브트리에 있다 + if cur.val < num + cur = cur.right + # 목표 노드는 cur의 왼쪽 서브트리에 있다 + elsif cur.val > num + cur = cur.left + # 목표 노드를 찾았으므로 반복문을 종료한다 + else + break + end + end + + cur + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 2.   노드 삽입 + +삽입할 원소 `num`이 주어졌을 때 '왼쪽 서브트리 < 루트 < 오른쪽 서브트리'라는 성질을 유지하려면 아래 그림(7-18)과 같이 삽입한다. + +1. **삽입 위치 찾기**: 탐색과 비슷하게 루트에서 출발하여 현재 노드 값과 `num`의 대소 관계에 따라 아래로 탐색한다. 리프를 지나 `None`에 도달하면 반복문을 종료한다. +2. **그 위치에 노드 삽입**: 값이 `num`인 노드를 초기화하여 `None` 자리에 놓는다. + +![이진 탐색 트리에 노드 삽입](binary_search_tree.assets/bst_insert.png){ class="animation-figure" } + +

그림 7-18   이진 탐색 트리에 노드 삽입

+ +구현 시 다음 두 가지에 주의하자. + +- 중복 노드가 있으면 정의에 어긋나므로 이진 탐색 트리는 중복을 허용하지 않는다. 삽입할 노드가 이미 있으면 삽입하지 않고 바로 반환한다. +- 삽입을 위해 노드 `pre`에 직전 반복의 노드를 저장해야 한다. 그러면 `None`에 도달했을 때 그 부모를 얻어 삽입을 완료할 수 있다. + +=== "Python" + + ```python title="binary_search_tree.py" + def insert(self, num: int): + """노드 삽입""" + # 트리가 비어 있으면 루트 노드를 초기화한다 + if self._root is None: + self._root = TreeNode(num) + return + # 반복하여 탐색하다 리프 노드를 지나면 종료한다 + cur, pre = self._root, None + while cur is not None: + # 중복 노드를 찾으면 바로 반환한다 + if cur.val == num: + return + pre = cur + # 삽입 위치는 cur의 오른쪽 서브트리에 있다 + if cur.val < num: + cur = cur.right + # 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else: + cur = cur.left + # 노드 삽입 + node = TreeNode(num) + if pre.val < num: + pre.right = node + else: + pre.left = node + ``` + +=== "C++" + + ```cpp title="binary_search_tree.cpp" + /* 노드 삽입 */ + void insert(int num) { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if (root == nullptr) { + root = new TreeNode(num); + return; + } + TreeNode *cur = root, *pre = nullptr; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != nullptr) { + // 중복 노드를 찾으면 바로 반환한다 + if (cur->val == num) + return; + pre = cur; + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + if (cur->val < num) + cur = cur->right; + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else + cur = cur->left; + } + // 노드 삽입 + TreeNode *node = new TreeNode(num); + if (pre->val < num) + pre->right = node; + else + pre->left = node; + } + ``` + +=== "Java" + + ```java title="binary_search_tree.java" + /* 노드 삽입 */ + void insert(int num) { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if (root == null) { + root = new TreeNode(num); + return; + } + TreeNode cur = root, pre = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 중복 노드를 찾으면 바로 반환한다 + if (cur.val == num) + return; + pre = cur; + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) + cur = cur.right; + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else + cur = cur.left; + } + // 노드 삽입 + TreeNode node = new TreeNode(num); + if (pre.val < num) + pre.right = node; + else + pre.left = node; + } + ``` + +=== "C#" + + ```csharp title="binary_search_tree.cs" + /* 노드 삽입 */ + void Insert(int num) { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if (root == null) { + root = new TreeNode(num); + return; + } + TreeNode? cur = root, pre = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 중복 노드를 찾으면 바로 반환한다 + if (cur.val == num) + return; + pre = cur; + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) + cur = cur.right; + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else + cur = cur.left; + } + + // 노드 삽입 + TreeNode node = new(num); + if (pre != null) { + if (pre.val < num) + pre.right = node; + else + pre.left = node; + } + } + ``` + +=== "Go" + + ```go title="binary_search_tree.go" + /* 노드 삽입 */ + func (bst *binarySearchTree) insert(num int) { + cur := bst.root + // 트리가 비어 있으면 루트 노드를 초기화한다 + if cur == nil { + bst.root = NewTreeNode(num) + return + } + // 삽입할 노드의 부모가 될 노드 위치 + var pre *TreeNode = nil + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + for cur != nil { + if cur.Val == num { + return + } + pre = cur + if cur.Val.(int) < num { + cur = cur.Right + } else { + cur = cur.Left + } + } + // 노드 삽입 + node := NewTreeNode(num) + if pre.Val.(int) < num { + pre.Right = node + } else { + pre.Left = node + } + } + ``` + +=== "Swift" + + ```swift title="binary_search_tree.swift" + /* 노드 삽입 */ + func insert(num: Int) { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if root == nil { + root = TreeNode(x: num) + return + } + var cur = root + var pre: TreeNode? + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while cur != nil { + // 중복 노드를 찾으면 바로 반환한다 + if cur!.val == num { + return + } + pre = cur + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + if cur!.val < num { + cur = cur?.right + } + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else { + cur = cur?.left + } + } + // 노드 삽입 + let node = TreeNode(x: num) + if pre!.val < num { + pre?.right = node + } else { + pre?.left = node + } + } + ``` + +=== "JS" + + ```javascript title="binary_search_tree.js" + /* 노드 삽입 */ + insert(num) { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if (this.root === null) { + this.root = new TreeNode(num); + return; + } + let cur = this.root, + pre = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur !== null) { + // 중복 노드를 찾으면 바로 반환한다 + if (cur.val === num) return; + pre = cur; + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) cur = cur.right; + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else cur = cur.left; + } + // 노드 삽입 + const node = new TreeNode(num); + if (pre.val < num) pre.right = node; + else pre.left = node; + } + ``` + +=== "TS" + + ```typescript title="binary_search_tree.ts" + /* 노드 삽입 */ + insert(num: number): void { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if (this.root === null) { + this.root = new TreeNode(num); + return; + } + let cur: TreeNode | null = this.root, + pre: TreeNode | null = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur !== null) { + // 중복 노드를 찾으면 바로 반환한다 + if (cur.val === num) return; + pre = cur; + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) cur = cur.right; + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else cur = cur.left; + } + // 노드 삽입 + const node = new TreeNode(num); + if (pre!.val < num) pre!.right = node; + else pre!.left = node; + } + ``` + +=== "Dart" + + ```dart title="binary_search_tree.dart" + /* 노드 삽입 */ + void insert(int _num) { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if (_root == null) { + _root = TreeNode(_num); + return; + } + TreeNode? cur = _root; + TreeNode? pre = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 중복 노드를 찾으면 바로 반환한다 + if (cur.val == _num) return; + pre = cur; + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + if (cur.val < _num) + cur = cur.right; + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else + cur = cur.left; + } + // 노드 삽입 + TreeNode? node = TreeNode(_num); + if (pre!.val < _num) + pre.right = node; + else + pre.left = node; + } + ``` + +=== "Rust" + + ```rust title="binary_search_tree.rs" + /* 노드 삽입 */ + pub fn insert(&mut self, num: i32) { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if self.root.is_none() { + self.root = Some(TreeNode::new(num)); + return; + } + let mut cur = self.root.clone(); + let mut pre = None; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while let Some(node) = cur.clone() { + match num.cmp(&node.borrow().val) { + // 중복 노드를 찾으면 바로 반환한다 + Ordering::Equal => return, + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + Ordering::Greater => { + pre = cur.clone(); + cur = node.borrow().right.clone(); + } + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + Ordering::Less => { + pre = cur.clone(); + cur = node.borrow().left.clone(); + } + } + } + // 노드 삽입 + let pre = pre.unwrap(); + let node = Some(TreeNode::new(num)); + if num > pre.borrow().val { + pre.borrow_mut().right = node; + } else { + pre.borrow_mut().left = node; + } + } + ``` + +=== "C" + + ```c title="binary_search_tree.c" + /* 노드 삽입 */ + void insert(BinarySearchTree *bst, int num) { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if (bst->root == NULL) { + bst->root = newTreeNode(num); + return; + } + TreeNode *cur = bst->root, *pre = NULL; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != NULL) { + // 중복 노드를 찾으면 바로 반환한다 + if (cur->val == num) { + return; + } + pre = cur; + if (cur->val < num) { + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + cur = cur->right; + } else { + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + cur = cur->left; + } + } + // 노드 삽입 + TreeNode *node = newTreeNode(num); + if (pre->val < num) { + pre->right = node; + } else { + pre->left = node; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search_tree.kt" + /* 노드 삽입 */ + fun insert(num: Int) { + // 트리가 비어 있으면 루트 노드를 초기화한다 + if (root == null) { + root = TreeNode(num) + return + } + var cur = root + var pre: TreeNode? = null + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 중복 노드를 찾으면 바로 반환한다 + if (cur._val == num) + return + pre = cur + // 삽입 위치는 cur의 오른쪽 서브트리에 있다 + cur = if (cur._val < num) + cur.right + // 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else + cur.left + } + // 노드 삽입 + val node = TreeNode(num) + if (pre?._val!! < num) + pre.right = node + else + pre.left = node + } + ``` + +=== "Ruby" + + ```ruby title="binary_search_tree.rb" + ### 노드 삽입 ### + def insert(num) + # 트리가 비어 있으면 루트 노드를 초기화한다 + if @root.nil? + @root = TreeNode.new(num) + return + end + + # 반복하여 탐색하다 리프 노드를 지나면 종료한다 + cur, pre = @root, nil + while !cur.nil? + # 중복 노드를 찾으면 바로 반환한다 + return if cur.val == num + + pre = cur + # 삽입 위치는 cur의 오른쪽 서브트리에 있다 + if cur.val < num + cur = cur.right + # 삽입 위치는 cur의 왼쪽 서브트리에 있다 + else + cur = cur.left + end + end + + # 노드 삽입 + node = TreeNode.new(num) + if pre.val < num + pre.right = node + else + pre.left = node + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +노드 탐색과 마찬가지로 삽입에는 $O(\log n)$ 시간이 걸린다. + +### 3.   노드 삭제 + +먼저 트리에서 목표 노드를 찾은 뒤 삭제한다. 삽입과 마찬가지로 삭제 후에도 '왼쪽 서브트리 < 루트 < 오른쪽 서브트리'라는 성질을 유지해야 한다. 따라서 목표 노드의 자식 수가 0, 1, 2인 세 경우로 나누어 삭제한다. + +아래 그림(7-19)과 같이 삭제할 노드의 차수가 $0$이면 리프 노드이므로 바로 삭제할 수 있다. + +![이진 탐색 트리의 노드 삭제: 차수 0](binary_search_tree.assets/bst_remove_case1.png){ class="animation-figure" } + +

그림 7-19   이진 탐색 트리의 노드 삭제: 차수 0

+ +아래 그림(7-20)과 같이 차수가 $1$이면 삭제할 노드를 그 자식으로 바꾸면 된다. + +![이진 탐색 트리의 노드 삭제: 차수 1](binary_search_tree.assets/bst_remove_case2.png){ class="animation-figure" } + +

그림 7-20   이진 탐색 트리의 노드 삭제: 차수 1

+ +차수가 $2$이면 바로 삭제할 수 없어 다른 노드로 대체해야 한다. '왼쪽 서브트리 $<$ 루트 $<$ 오른쪽 서브트리' 성질을 유지하려면 **오른쪽 서브트리의 최소 노드 또는 왼쪽 서브트리의 최대 노드로 대체할 수 있다**. + +오른쪽 서브트리의 최소 노드(중위 순회에서 다음 노드)를 선택하면 삭제 과정은 아래 그림(7-21)과 같다. + +1. 삭제할 노드의 '중위 순회 순서'상 다음 노드를 찾아 `tmp`라고 한다. +2. 삭제할 노드의 값을 `tmp`의 값으로 덮어쓰고, 트리에서 노드 `tmp`를 재귀적으로 삭제한다. + +=== "<1>" + ![이진 탐색 트리의 노드 삭제: 차수 2](binary_search_tree.assets/bst_remove_case3_step1.png){ class="animation-figure" } + +=== "<2>" + ![차수 2인 노드 삭제 2단계](binary_search_tree.assets/bst_remove_case3_step2.png){ class="animation-figure" } + +=== "<3>" + ![차수 2인 노드 삭제 3단계](binary_search_tree.assets/bst_remove_case3_step3.png){ class="animation-figure" } + +=== "<4>" + ![차수 2인 노드 삭제 4단계](binary_search_tree.assets/bst_remove_case3_step4.png){ class="animation-figure" } + +

그림 7-21   이진 탐색 트리의 노드 삭제: 차수 2

+ +노드 삭제에도 $O(\log n)$ 시간이 걸린다. 삭제할 노드를 찾는 데 $O(\log n)$, 중위 순회의 후속 노드를 구하는 데 $O(\log n)$ 시간이 든다. 예시 코드는 다음과 같다. + +=== "Python" + + ```python title="binary_search_tree.py" + def remove(self, num: int): + """노드 삭제""" + # 트리가 비어 있으면 바로 반환하여 종료한다 + if self._root is None: + return + # 반복하여 탐색하다 리프 노드를 지나면 종료한다 + cur, pre = self._root, None + while cur is not None: + # 삭제할 노드를 찾았으므로 반복문을 종료한다 + if cur.val == num: + break + pre = cur + # 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + if cur.val < num: + cur = cur.right + # 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else: + cur = cur.left + # 삭제할 노드가 없으면 바로 반환한다 + if cur is None: + return + + # 자식 노드 수 = 0 또는 1 + if cur.left is None or cur.right is None: + # 자식 노드 수 = 0 / 1이면 child = null / 해당 자식 노드이다 + child = cur.left or cur.right + # 노드 cur를 삭제한다 + if cur != self._root: + if pre.left == cur: + pre.left = child + else: + pre.right = child + else: + # 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + self._root = child + # 자식 노드 수 = 2 + else: + # 중위 순회에서 cur의 다음 노드를 구한다 + tmp: TreeNode = cur.right + while tmp.left is not None: + tmp = tmp.left + # 노드 tmp를 재귀적으로 삭제한다 + self.remove(tmp.val) + # tmp로 cur를 덮어쓴다 + cur.val = tmp.val + ``` + +=== "C++" + + ```cpp title="binary_search_tree.cpp" + /* 노드 삭제 */ + void remove(int num) { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if (root == nullptr) + return; + TreeNode *cur = root, *pre = nullptr; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != nullptr) { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + if (cur->val == num) + break; + pre = cur; + // 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + if (cur->val < num) + cur = cur->right; + // 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else + cur = cur->left; + } + // 삭제할 노드가 없으면 바로 반환한다 + if (cur == nullptr) + return; + // 자식 노드 수 = 0 또는 1 + if (cur->left == nullptr || cur->right == nullptr) { + // 자식 노드 수가 0 / 1이면 child = nullptr / 해당 자식 노드다 + TreeNode *child = cur->left != nullptr ? cur->left : cur->right; + // 노드 cur를 삭제한다 + if (cur != root) { + if (pre->left == cur) + pre->left = child; + else + pre->right = child; + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + root = child; + } + // 메모리 해제 + delete cur; + } + // 자식 노드 수 = 2 + else { + // 중위 순회에서 cur의 다음 노드를 구한다 + TreeNode *tmp = cur->right; + while (tmp->left != nullptr) { + tmp = tmp->left; + } + int tmpVal = tmp->val; + // 노드 tmp를 재귀적으로 삭제한다 + remove(tmp->val); + // tmp로 cur를 덮어쓴다 + cur->val = tmpVal; + } + } + ``` + +=== "Java" + + ```java title="binary_search_tree.java" + /* 노드 삭제 */ + void remove(int num) { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if (root == null) + return; + TreeNode cur = root, pre = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + if (cur.val == num) + break; + pre = cur; + // 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) + cur = cur.right; + // 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else + cur = cur.left; + } + // 삭제할 노드가 없으면 바로 반환한다 + if (cur == null) + return; + // 자식 노드 수 = 0 또는 1 + if (cur.left == null || cur.right == null) { + // 자식 노드 수 = 0 / 1이면 child = null / 해당 자식 노드이다 + TreeNode child = cur.left != null ? cur.left : cur.right; + // 노드 cur를 삭제한다 + if (cur != root) { + if (pre.left == cur) + pre.left = child; + else + pre.right = child; + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + root = child; + } + } + // 자식 노드 수 = 2 + else { + // 중위 순회에서 cur의 다음 노드를 구한다 + TreeNode tmp = cur.right; + while (tmp.left != null) { + tmp = tmp.left; + } + // 노드 tmp를 재귀적으로 삭제한다 + remove(tmp.val); + // tmp로 cur를 덮어쓴다 + cur.val = tmp.val; + } + } + ``` + +=== "C#" + + ```csharp title="binary_search_tree.cs" + /* 노드 삭제 */ + void Remove(int num) { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if (root == null) + return; + TreeNode? cur = root, pre = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + if (cur.val == num) + break; + pre = cur; + // 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) + cur = cur.right; + // 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else + cur = cur.left; + } + // 삭제할 노드가 없으면 바로 반환한다 + if (cur == null) + return; + // 자식 노드 수 = 0 또는 1 + if (cur.left == null || cur.right == null) { + // 자식 노드 수 = 0 / 1이면 child = null / 해당 자식 노드이다 + TreeNode? child = cur.left ?? cur.right; + // 노드 cur를 삭제한다 + if (cur != root) { + if (pre!.left == cur) + pre.left = child; + else + pre.right = child; + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + root = child; + } + } + // 자식 노드 수 = 2 + else { + // 중위 순회에서 cur의 다음 노드를 구한다 + TreeNode? tmp = cur.right; + while (tmp.left != null) { + tmp = tmp.left; + } + // 노드 tmp를 재귀적으로 삭제한다 + Remove(tmp.val!.Value); + // tmp로 cur를 덮어쓴다 + cur.val = tmp.val; + } + } + ``` + +=== "Go" + + ```go title="binary_search_tree.go" + /* 노드 삭제 */ + func (bst *binarySearchTree) remove(num int) { + cur := bst.root + // 트리가 비어 있으면 바로 반환하여 종료한다 + if cur == nil { + return + } + // 삭제할 노드의 부모 노드 위치 + var pre *TreeNode = nil + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + for cur != nil { + if cur.Val == num { + break + } + pre = cur + if cur.Val.(int) < num { + // 삭제할 노드는 오른쪽 서브트리에 있다 + cur = cur.Right + } else { + // 삭제할 노드는 왼쪽 서브트리에 있다 + cur = cur.Left + } + } + // 삭제할 노드가 없으면 바로 반환한다 + if cur == nil { + return + } + // 자식 노드 수가 0 또는 1 + if cur.Left == nil || cur.Right == nil { + var child *TreeNode = nil + // 삭제할 노드의 자식 노드를 구한다 + if cur.Left != nil { + child = cur.Left + } else { + child = cur.Right + } + // 노드 cur를 삭제한다 + if cur != bst.root { + if pre.Left == cur { + pre.Left = child + } else { + pre.Right = child + } + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + bst.root = child + } + // 자식 노드 수가 2 + } else { + // 중위 순회에서 삭제할 노드 cur의 다음 노드를 구한다 + tmp := cur.Right + for tmp.Left != nil { + tmp = tmp.Left + } + // 노드 tmp를 재귀적으로 삭제한다 + bst.remove(tmp.Val.(int)) + // tmp로 cur를 덮어쓴다 + cur.Val = tmp.Val + } + } + ``` + +=== "Swift" + + ```swift title="binary_search_tree.swift" + /* 노드 삭제 */ + func remove(num: Int) { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if root == nil { + return + } + var cur = root + var pre: TreeNode? + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while cur != nil { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + if cur!.val == num { + break + } + pre = cur + // 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + if cur!.val < num { + cur = cur?.right + } + // 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else { + cur = cur?.left + } + } + // 삭제할 노드가 없으면 바로 반환한다 + if cur == nil { + return + } + // 자식 노드 수 = 0 또는 1 + if cur?.left == nil || cur?.right == nil { + // 자식 노드 수 = 0 / 1이면 child = null / 해당 자식 노드이다 + let child = cur?.left ?? cur?.right + // 노드 cur를 삭제한다 + if cur !== root { + if pre?.left === cur { + pre?.left = child + } else { + pre?.right = child + } + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + root = child + } + } + // 자식 노드 수 = 2 + else { + // 중위 순회에서 cur의 다음 노드를 구한다 + var tmp = cur?.right + while tmp?.left != nil { + tmp = tmp?.left + } + // 노드 tmp를 재귀적으로 삭제한다 + remove(num: tmp!.val) + // tmp로 cur를 덮어쓴다 + cur?.val = tmp!.val + } + } + ``` + +=== "JS" + + ```javascript title="binary_search_tree.js" + /* 노드 삭제 */ + remove(num) { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if (this.root === null) return; + let cur = this.root, + pre = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur !== null) { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + if (cur.val === num) break; + pre = cur; + // 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) cur = cur.right; + // 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else cur = cur.left; + } + // 삭제할 노드가 없으면 바로 반환한다 + if (cur === null) return; + // 자식 노드 수 = 0 또는 1 + if (cur.left === null || cur.right === null) { + // 자식 노드 수 = 0 / 1이면 child = null / 해당 자식 노드이다 + const child = cur.left !== null ? cur.left : cur.right; + // 노드 cur를 삭제한다 + if (cur !== this.root) { + if (pre.left === cur) pre.left = child; + else pre.right = child; + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + this.root = child; + } + } + // 자식 노드 수 = 2 + else { + // 중위 순회에서 cur의 다음 노드를 구한다 + let tmp = cur.right; + while (tmp.left !== null) { + tmp = tmp.left; + } + // 노드 tmp를 재귀적으로 삭제한다 + this.remove(tmp.val); + // tmp로 cur를 덮어쓴다 + cur.val = tmp.val; + } + } + ``` + +=== "TS" + + ```typescript title="binary_search_tree.ts" + /* 노드 삭제 */ + remove(num: number): void { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if (this.root === null) return; + let cur: TreeNode | null = this.root, + pre: TreeNode | null = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur !== null) { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + if (cur.val === num) break; + pre = cur; + // 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < num) cur = cur.right; + // 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else cur = cur.left; + } + // 삭제할 노드가 없으면 바로 반환한다 + if (cur === null) return; + // 자식 노드 수 = 0 또는 1 + if (cur.left === null || cur.right === null) { + // 자식 노드 수 = 0 / 1이면 child = null / 해당 자식 노드이다 + const child: TreeNode | null = + cur.left !== null ? cur.left : cur.right; + // 노드 cur를 삭제한다 + if (cur !== this.root) { + if (pre!.left === cur) pre!.left = child; + else pre!.right = child; + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + this.root = child; + } + } + // 자식 노드 수 = 2 + else { + // 중위 순회에서 cur의 다음 노드를 구한다 + let tmp: TreeNode | null = cur.right; + while (tmp!.left !== null) { + tmp = tmp!.left; + } + // 노드 tmp를 재귀적으로 삭제한다 + this.remove(tmp!.val); + // tmp로 cur를 덮어쓴다 + cur.val = tmp!.val; + } + } + ``` + +=== "Dart" + + ```dart title="binary_search_tree.dart" + /* 노드 삭제 */ + void remove(int _num) { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if (_root == null) return; + TreeNode? cur = _root; + TreeNode? pre = null; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + if (cur.val == _num) break; + pre = cur; + // 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + if (cur.val < _num) + cur = cur.right; + // 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else + cur = cur.left; + } + // 삭제할 노드가 없으면 바로 반환한다 + if (cur == null) return; + // 자식 노드 수 = 0 또는 1 + if (cur.left == null || cur.right == null) { + // 자식 노드 수 = 0 / 1이면 child = null / 해당 자식 노드이다 + TreeNode? child = cur.left ?? cur.right; + // 노드 cur를 삭제한다 + if (cur != _root) { + if (pre!.left == cur) + pre.left = child; + else + pre.right = child; + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + _root = child; + } + } else { + // 자식 노드 수 = 2 + // 중위 순회에서 cur의 다음 노드를 구한다 + TreeNode? tmp = cur.right; + while (tmp!.left != null) { + tmp = tmp.left; + } + // 노드 tmp를 재귀적으로 삭제한다 + remove(tmp.val); + // tmp로 cur를 덮어쓴다 + cur.val = tmp.val; + } + } + ``` + +=== "Rust" + + ```rust title="binary_search_tree.rs" + /* 노드 삭제 */ + pub fn remove(&mut self, num: i32) { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if self.root.is_none() { + return; + } + let mut cur = self.root.clone(); + let mut pre = None; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while let Some(node) = cur.clone() { + match num.cmp(&node.borrow().val) { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + Ordering::Equal => break, + // 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + Ordering::Greater => { + pre = cur.clone(); + cur = node.borrow().right.clone(); + } + // 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + Ordering::Less => { + pre = cur.clone(); + cur = node.borrow().left.clone(); + } + } + } + // 삭제할 노드가 없으면 바로 반환한다 + if cur.is_none() { + return; + } + let cur = cur.unwrap(); + let (left_child, right_child) = (cur.borrow().left.clone(), cur.borrow().right.clone()); + match (left_child.clone(), right_child.clone()) { + // 자식 노드 수 = 0 또는 1 + (None, None) | (Some(_), None) | (None, Some(_)) => { + // 자식 노드 수가 0 / 1이면 child = nullptr / 해당 자식 노드다 + let child = left_child.or(right_child); + let pre = pre.unwrap(); + // 노드 cur를 삭제한다 + if !Rc::ptr_eq(&cur, self.root.as_ref().unwrap()) { + let left = pre.borrow().left.clone(); + if left.is_some() && Rc::ptr_eq(left.as_ref().unwrap(), &cur) { + pre.borrow_mut().left = child; + } else { + pre.borrow_mut().right = child; + } + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + self.root = child; + } + } + // 자식 노드 수 = 2 + (Some(_), Some(_)) => { + // 중위 순회에서 cur의 다음 노드를 구한다 + let mut tmp = cur.borrow().right.clone(); + while let Some(node) = tmp.clone() { + if node.borrow().left.is_some() { + tmp = node.borrow().left.clone(); + } else { + break; + } + } + let tmp_val = tmp.unwrap().borrow().val; + // 노드 tmp를 재귀적으로 삭제한다 + self.remove(tmp_val); + // tmp로 cur를 덮어쓴다 + cur.borrow_mut().val = tmp_val; + } + } + } + ``` + +=== "C" + + ```c title="binary_search_tree.c" + /* 노드 삭제 */ + // stdio.h를 포함했으므로 여기서는 remove라는 이름을 사용할 수 없다 + void removeItem(BinarySearchTree *bst, int num) { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if (bst->root == NULL) + return; + TreeNode *cur = bst->root, *pre = NULL; + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != NULL) { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + if (cur->val == num) + break; + pre = cur; + if (cur->val < num) { + // 삭제할 노드가 root의 오른쪽 서브트리에 있다 + cur = cur->right; + } else { + // 삭제할 노드가 root의 왼쪽 서브트리에 있다 + cur = cur->left; + } + } + // 삭제할 노드가 없으면 바로 반환한다 + if (cur == NULL) + return; + // 삭제할 노드에 자식 노드가 있는지 확인한다 + if (cur->left == NULL || cur->right == NULL) { + /* 자식 노드 수 = 0 또는 1 */ + // 자식 노드 수가 0 / 1이면 child = nullptr / 해당 자식 노드다 + TreeNode *child = cur->left != NULL ? cur->left : cur->right; + // 노드 cur를 삭제한다 + if (pre->left == cur) { + pre->left = child; + } else { + pre->right = child; + } + // 메모리 해제 + free(cur); + } else { + /* 자식 노드 수 = 2 */ + // 중위 순회에서 cur의 다음 노드를 구한다 + TreeNode *tmp = cur->right; + while (tmp->left != NULL) { + tmp = tmp->left; + } + int tmpVal = tmp->val; + // 노드 tmp를 재귀적으로 삭제한다 + removeItem(bst, tmp->val); + // tmp로 cur를 덮어쓴다 + cur->val = tmpVal; + } + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_search_tree.kt" + /* 노드 삭제 */ + fun remove(num: Int) { + // 트리가 비어 있으면 바로 반환하여 종료한다 + if (root == null) + return + var cur = root + var pre: TreeNode? = null + // 반복하여 탐색하다 리프 노드를 지나면 종료한다 + while (cur != null) { + // 삭제할 노드를 찾았으므로 반복문을 종료한다 + if (cur._val == num) + break + pre = cur + // 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + cur = if (cur._val < num) + cur.right + // 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else + cur.left + } + // 삭제할 노드가 없으면 바로 반환한다 + if (cur == null) + return + // 자식 노드 수 = 0 또는 1 + if (cur.left == null || cur.right == null) { + // 자식 노드 수 = 0 / 1이면 child = null / 해당 자식 노드이다 + val child = if (cur.left != null) + cur.left + else + cur.right + // 노드 cur를 삭제한다 + if (cur != root) { + if (pre!!.left == cur) + pre.left = child + else + pre.right = child + } else { + // 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + root = child + } + // 자식 노드 수 = 2 + } else { + // 중위 순회에서 cur의 다음 노드를 구한다 + var tmp = cur.right + while (tmp!!.left != null) { + tmp = tmp.left + } + // 노드 tmp를 재귀적으로 삭제한다 + remove(tmp._val) + // tmp로 cur를 덮어쓴다 + cur._val = tmp._val + } + } + ``` + +=== "Ruby" + + ```ruby title="binary_search_tree.rb" + ### 노드 삭제 ### + def remove(num) + # 트리가 비어 있으면 바로 반환하여 종료한다 + return if @root.nil? + + # 반복하여 탐색하다 리프 노드를 지나면 종료한다 + cur, pre = @root, nil + while !cur.nil? + # 삭제할 노드를 찾았으므로 반복문을 종료한다 + break if cur.val == num + + pre = cur + # 삭제할 노드는 cur의 오른쪽 서브트리에 있다 + if cur.val < num + cur = cur.right + # 삭제할 노드는 cur의 왼쪽 서브트리에 있다 + else + cur = cur.left + end + end + # 삭제할 노드가 없으면 바로 반환한다 + return if cur.nil? + + # 자식 노드 수 = 0 또는 1 + if cur.left.nil? || cur.right.nil? + # 자식 노드 수 = 0 / 1이면 child = null / 해당 자식 노드이다 + child = cur.left || cur.right + # 노드 cur를 삭제한다 + if cur != @root + if pre.left == cur + pre.left = child + else + pre.right = child + end + else + # 삭제할 노드가 루트 노드이면 루트 노드를 다시 지정한다 + @root = child + end + # 자식 노드 수 = 2 + else + # 중위 순회에서 cur의 다음 노드를 구한다 + tmp = cur.right + while !tmp.left.nil? + tmp = tmp.left + end + # 노드 tmp를 재귀적으로 삭제한다 + remove(tmp.val) + # tmp로 cur를 덮어쓴다 + cur.val = tmp.val + end + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 4.   중위 순회 결과의 정렬 성질 + +아래 그림(7-22)과 같이 이진 트리의 중위 순회는 '왼쪽 $\rightarrow$ 루트 $\rightarrow$ 오른쪽' 순서이며, 이진 탐색 트리는 '왼쪽 자식 $<$ 루트 $<$ 오른쪽 자식'의 대소 관계를 만족한다. + +따라서 중위 순회에서는 항상 다음으로 작은 노드를 먼저 방문한다. 여기서 **이진 탐색 트리의 중위 순회 결과는 오름차순이다**라는 중요한 성질을 얻는다. + +이 성질을 이용하면 별도 정렬 없이 $O(n)$ 시간만으로 정렬된 데이터를 얻을 수 있어 매우 효율적이다. + +![이진 탐색 트리의 중위 순회 결과](binary_search_tree.assets/bst_inorder_traversal.png){ class="animation-figure" } + +

그림 7-22   이진 탐색 트리의 중위 순회 결과

+ +## 7.4.2   이진 탐색 트리의 효율 + +데이터 집합을 배열이나 이진 탐색 트리에 저장한다고 하자. 아래 표(7-2)를 보면 이진 탐색 트리의 각 연산은 로그 시간 복잡도로 안정적이고 효율적이다. 데이터 추가는 잦고 탐색과 삭제는 드문 상황에서만 배열이 더 효율적이다. + +

표 7-2   배열과 탐색 트리의 효율 비교

+ +
+ +| | 정렬되지 않은 배열 | 이진 탐색 트리 | +| -------- | -------- | ----------- | +| 원소 탐색 | $O(n)$ | $O(\log n)$ | +| 원소 삽입 | $O(1)$ | $O(\log n)$ | +| 원소 삭제 | $O(n)$ | $O(\log n)$ | + +
+ +이상적인 경우 이진 탐색 트리는 '균형'을 이루므로 어떤 노드든 $\log n$ 회의 반복 안에 찾을 수 있다. + +하지만 삽입과 삭제를 계속하다 보면 아래 그림(7-23)의 연결 리스트로 퇴화할 수 있다. 이때 각 연산의 시간 복잡도도 $O(n)$으로 저하된다. + +![이진 탐색 트리의 퇴화](binary_search_tree.assets/bst_degradation.png){ class="animation-figure" } + +

그림 7-23   이진 탐색 트리의 퇴화

+ +## 7.4.3   이진 탐색 트리의 주요 응용 + +- 시스템의 다단계 인덱스로 사용하여 효율적인 탐색, 삽입, 삭제를 구현한다. +- 일부 탐색 알고리즘의 기반 자료 구조로 사용한다. +- 데이터 스트림을 저장하면서 정렬 상태를 유지한다. diff --git a/ko/docs/chapter_tree/binary_tree.md b/ko/docs/chapter_tree/binary_tree.md new file mode 100644 index 000000000..87594a665 --- /dev/null +++ b/ko/docs/chapter_tree/binary_tree.md @@ -0,0 +1,700 @@ +--- +comments: true +--- + +# 7.1   이진 트리 + +이진 트리(binary tree)는 '조상'과 '후손' 사이의 파생 관계를 나타내는 비선형 자료 구조로, '하나를 둘로 나누는' 분할 정복의 논리를 보여 준다. 연결 리스트처럼 기본 단위는 노드이며, 각 노드는 값과 왼쪽 자식 참조, 오른쪽 자식 참조를 포함한다. + +=== "Python" + + ```python title="" + class TreeNode: + """이진 트리 노드 클래스""" + def __init__(self, val: int): + self.val: int = val # 노드 값 + self.left: TreeNode | None = None # 왼쪽 자식 노드 참조 + self.right: TreeNode | None = None # 오른쪽 자식 노드 참조 + ``` + +=== "C++" + + ```cpp title="" + /* 이진 트리 노드 구조체 */ + struct TreeNode { + int val; // 노드 값 + TreeNode *left; // 왼쪽 자식 노드 포인터 + TreeNode *right; // 오른쪽 자식 노드 포인터 + TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} + }; + ``` + +=== "Java" + + ```java title="" + /* 이진 트리 노드 클래스 */ + class TreeNode { + int val; // 노드 값 + TreeNode left; // 왼쪽 자식 노드 참조 + TreeNode right; // 오른쪽 자식 노드 참조 + TreeNode(int x) { val = x; } + } + ``` + +=== "C#" + + ```csharp title="" + /* 이진 트리 노드 클래스 */ + class TreeNode(int? x) { + public int? val = x; // 노드 값 + public TreeNode? left; // 왼쪽 자식 노드 참조 + public TreeNode? right; // 오른쪽 자식 노드 참조 + } + ``` + +=== "Go" + + ```go title="" + /* 이진 트리 노드 구조체 */ + type TreeNode struct { + Val int + Left *TreeNode + Right *TreeNode + } + /* 생성자 */ + func NewTreeNode(v int) *TreeNode { + return &TreeNode{ + Left: nil, // 왼쪽 자식 노드 포인터 + Right: nil, // 오른쪽 자식 노드 포인터 + Val: v, // 노드 값 + } + } + ``` + +=== "Swift" + + ```swift title="" + /* 이진 트리 노드 클래스 */ + class TreeNode { + var val: Int // 노드 값 + var left: TreeNode? // 왼쪽 자식 노드 참조 + var right: TreeNode? // 오른쪽 자식 노드 참조 + + init(x: Int) { + val = x + } + } + ``` + +=== "JS" + + ```javascript title="" + /* 이진 트리 노드 클래스 */ + class TreeNode { + val; // 노드 값 + left; // 왼쪽 자식 노드 포인터 + right; // 오른쪽 자식 노드 포인터 + constructor(val, left, right) { + this.val = val === undefined ? 0 : val; + this.left = left === undefined ? null : left; + this.right = right === undefined ? null : right; + } + } + ``` + +=== "TS" + + ```typescript title="" + /* 이진 트리 노드 클래스 */ + class TreeNode { + val: number; + left: TreeNode | null; + right: TreeNode | null; + + constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) { + this.val = val === undefined ? 0 : val; // 노드 값 + this.left = left === undefined ? null : left; // 왼쪽 자식 노드 참조 + this.right = right === undefined ? null : right; // 오른쪽 자식 노드 참조 + } + } + ``` + +=== "Dart" + + ```dart title="" + /* 이진 트리 노드 클래스 */ + class TreeNode { + int val; // 노드 값 + TreeNode? left; // 왼쪽 자식 노드 참조 + TreeNode? right; // 오른쪽 자식 노드 참조 + TreeNode(this.val, [this.left, this.right]); + } + ``` + +=== "Rust" + + ```rust title="" + use std::rc::Rc; + use std::cell::RefCell; + + /* 이진 트리 노드 구조체 */ + struct TreeNode { + val: i32, // 노드 값 + left: Option>>, // 왼쪽 자식 노드 참조 + right: Option>>, // 오른쪽 자식 노드 참조 + } + + impl TreeNode { + /* 생성자 */ + fn new(val: i32) -> Rc> { + Rc::new(RefCell::new(Self { + val, + left: None, + right: None + })) + } + } + ``` + +=== "C" + + ```c title="" + /* 이진 트리 노드 구조체 */ + typedef struct TreeNode { + int val; // 노드 값 + int height; // 노드 높이 + struct TreeNode *left; // 왼쪽 자식 노드 포인터 + struct TreeNode *right; // 오른쪽 자식 노드 포인터 + } TreeNode; + + /* 생성자 */ + TreeNode *newTreeNode(int val) { + TreeNode *node; + + node = (TreeNode *)malloc(sizeof(TreeNode)); + node->val = val; + node->height = 0; + node->left = NULL; + node->right = NULL; + return node; + } + ``` + +=== "Kotlin" + + ```kotlin title="" + /* 이진 트리 노드 클래스 */ + class TreeNode(val _val: Int) { // 노드 값 + var left: TreeNode? = null // 왼쪽 자식 노드 참조 + var right: TreeNode? = null // 오른쪽 자식 노드 참조 + } + ``` + +=== "Ruby" + + ```ruby title="" + ### 이진 트리 노드 클래스 ### + class TreeNode + attr_accessor :val # 노드 값 + attr_accessor :left # 왼쪽 자식 노드 참조 + attr_accessor :right # 오른쪽 자식 노드 참조 + + def initialize(val) + @val = val + end + end + ``` + +각 노드는 두 참조(포인터)를 가지며, 각각 왼쪽 자식 노드(left-child node)와 오른쪽 자식 노드(right-child node)를 가리킨다. 해당 노드는 두 자식의 부모 노드(parent node)라고 한다. 어떤 노드의 왼쪽 자식과 그 아래 노드들이 이루는 트리를 그 노드의 왼쪽 서브트리(left subtree)라고 하며, 같은 방식으로 오른쪽 서브트리(right subtree)를 정의한다. + +**이진 트리에서 리프 노드를 제외한 모든 노드에는 자식 노드와 비어 있지 않은 서브트리가 있다**. 아래 그림(7-1)에서 '노드 2'를 부모로 보면 왼쪽과 오른쪽 자식은 각각 '노드 4', '노드 5'다. 왼쪽 서브트리는 '노드 4와 그 아래 노드들이 이루는 트리', 오른쪽 서브트리는 '노드 5와 그 아래 노드들이 이루는 트리'다. + +![부모 노드, 자식 노드, 서브트리](binary_tree.assets/binary_tree_definition.png){ class="animation-figure" } + +

그림 7-1   부모 노드, 자식 노드, 서브트리

+ +## 7.1.1   이진 트리의 주요 용어 + +이진 트리에서 자주 쓰는 용어는 아래 그림(7-2)과 같다. + +- 루트 노드(root node): 이진 트리의 최상위 노드로, 부모가 없다. +- 리프 노드(leaf node): 자식이 없는 노드로, 두 포인터가 모두 `None`을 가리킨다. +- 간선(edge): 두 노드를 잇는 선으로, 노드 참조(포인터)에 해당한다. +- 노드의 레벨(level): 위에서 아래로 증가하며 루트의 레벨은 1이다. +- 노드의 차수(degree): 자식 노드의 수다. 이진 트리에서는 0, 1, 2 중 하나다. +- 이진 트리의 높이(height): 루트에서 가장 먼 리프까지 거치는 간선 수다. +- 노드의 깊이(depth): 루트에서 해당 노드까지 거치는 간선 수다. +- 노드의 높이(height): 해당 노드에서 가장 먼 리프와 해당 노드 사이에 있는 간선 수다. + +![이진 트리의 주요 용어](binary_tree.assets/binary_tree_terminology.png){ class="animation-figure" } + +

그림 7-2   이진 트리의 주요 용어

+ +!!! tip "참고" + + 보통 '높이'와 '깊이'를 '거치는 간선 수'로 정의하지만, 일부 문제나 교재는 '거치는 노드 수'로 정의하기도 한다. 그런 경우에는 높이와 깊이에 각각 1을 더해야 한다. + +## 7.1.2   이진 트리의 기본 연산 + +### 1.   이진 트리 초기화 + +연결 리스트처럼 먼저 노드를 초기화하고 참조(포인터)를 연결한다. + +=== "Python" + + ```python title="binary_tree.py" + # 이진 트리 초기화 + # 노드 초기화 + n1 = TreeNode(val=1) + n2 = TreeNode(val=2) + n3 = TreeNode(val=3) + n4 = TreeNode(val=4) + n5 = TreeNode(val=5) + # 노드 사이의 참조(포인터) 연결 + n1.left = n2 + n1.right = n3 + n2.left = n4 + n2.right = n5 + ``` + +=== "C++" + + ```cpp title="binary_tree.cpp" + /* 이진 트리 초기화 */ + // 노드 초기화 + TreeNode* n1 = new TreeNode(1); + TreeNode* n2 = new TreeNode(2); + TreeNode* n3 = new TreeNode(3); + TreeNode* n4 = new TreeNode(4); + TreeNode* n5 = new TreeNode(5); + // 노드 사이의 참조(포인터) 연결 + n1->left = n2; + n1->right = n3; + n2->left = n4; + n2->right = n5; + ``` + +=== "Java" + + ```java title="binary_tree.java" + // 노드 초기화 + TreeNode n1 = new TreeNode(1); + TreeNode n2 = new TreeNode(2); + TreeNode n3 = new TreeNode(3); + TreeNode n4 = new TreeNode(4); + TreeNode n5 = new TreeNode(5); + // 노드 사이의 참조(포인터) 연결 + n1.left = n2; + n1.right = n3; + n2.left = n4; + n2.right = n5; + ``` + +=== "C#" + + ```csharp title="binary_tree.cs" + /* 이진 트리 초기화 */ + // 노드 초기화 + TreeNode n1 = new(1); + TreeNode n2 = new(2); + TreeNode n3 = new(3); + TreeNode n4 = new(4); + TreeNode n5 = new(5); + // 노드 사이의 참조(포인터) 연결 + n1.left = n2; + n1.right = n3; + n2.left = n4; + n2.right = n5; + ``` + +=== "Go" + + ```go title="binary_tree.go" + /* 이진 트리 초기화 */ + // 노드 초기화 + n1 := NewTreeNode(1) + n2 := NewTreeNode(2) + n3 := NewTreeNode(3) + n4 := NewTreeNode(4) + n5 := NewTreeNode(5) + // 노드 사이의 참조(포인터) 연결 + n1.Left = n2 + n1.Right = n3 + n2.Left = n4 + n2.Right = n5 + ``` + +=== "Swift" + + ```swift title="binary_tree.swift" + // 노드 초기화 + let n1 = TreeNode(x: 1) + let n2 = TreeNode(x: 2) + let n3 = TreeNode(x: 3) + let n4 = TreeNode(x: 4) + let n5 = TreeNode(x: 5) + // 노드 사이의 참조(포인터) 연결 + n1.left = n2 + n1.right = n3 + n2.left = n4 + n2.right = n5 + ``` + +=== "JS" + + ```javascript title="binary_tree.js" + /* 이진 트리 초기화 */ + // 노드 초기화 + let n1 = new TreeNode(1), + n2 = new TreeNode(2), + n3 = new TreeNode(3), + n4 = new TreeNode(4), + n5 = new TreeNode(5); + // 노드 사이의 참조(포인터) 연결 + n1.left = n2; + n1.right = n3; + n2.left = n4; + n2.right = n5; + ``` + +=== "TS" + + ```typescript title="binary_tree.ts" + /* 이진 트리 초기화 */ + // 노드 초기화 + let n1 = new TreeNode(1), + n2 = new TreeNode(2), + n3 = new TreeNode(3), + n4 = new TreeNode(4), + n5 = new TreeNode(5); + // 노드 사이의 참조(포인터) 연결 + n1.left = n2; + n1.right = n3; + n2.left = n4; + n2.right = n5; + ``` + +=== "Dart" + + ```dart title="binary_tree.dart" + /* 이진 트리 초기화 */ + // 노드 초기화 + TreeNode n1 = new TreeNode(1); + TreeNode n2 = new TreeNode(2); + TreeNode n3 = new TreeNode(3); + TreeNode n4 = new TreeNode(4); + TreeNode n5 = new TreeNode(5); + // 노드 사이의 참조(포인터) 연결 + n1.left = n2; + n1.right = n3; + n2.left = n4; + n2.right = n5; + ``` + +=== "Rust" + + ```rust title="binary_tree.rs" + // 노드 초기화 + let n1 = TreeNode::new(1); + let n2 = TreeNode::new(2); + let n3 = TreeNode::new(3); + let n4 = TreeNode::new(4); + let n5 = TreeNode::new(5); + // 노드 사이의 참조(포인터) 연결 + n1.borrow_mut().left = Some(n2.clone()); + n1.borrow_mut().right = Some(n3); + n2.borrow_mut().left = Some(n4); + n2.borrow_mut().right = Some(n5); + ``` + +=== "C" + + ```c title="binary_tree.c" + /* 이진 트리 초기화 */ + // 노드 초기화 + TreeNode *n1 = newTreeNode(1); + TreeNode *n2 = newTreeNode(2); + TreeNode *n3 = newTreeNode(3); + TreeNode *n4 = newTreeNode(4); + TreeNode *n5 = newTreeNode(5); + // 노드 사이의 참조(포인터) 연결 + n1->left = n2; + n1->right = n3; + n2->left = n4; + n2->right = n5; + ``` + +=== "Kotlin" + + ```kotlin title="binary_tree.kt" + // 노드 초기화 + val n1 = TreeNode(1) + val n2 = TreeNode(2) + val n3 = TreeNode(3) + val n4 = TreeNode(4) + val n5 = TreeNode(5) + // 노드 사이의 참조(포인터) 연결 + n1.left = n2 + n1.right = n3 + n2.left = n4 + n2.right = n5 + ``` + +=== "Ruby" + + ```ruby title="binary_tree.rb" + # 이진 트리 초기화 + # 노드 초기화 + n1 = TreeNode.new(1) + n2 = TreeNode.new(2) + n3 = TreeNode.new(3) + n4 = TreeNode.new(4) + n5 = TreeNode.new(5) + # 노드 사이의 참조(포인터) 연결 + n1.left = n2 + n1.right = n3 + n2.left = n4 + n2.right = n5 + ``` + +??? pythontutor "실행 시각화" + +
+ + +### 2.   노드 삽입과 삭제 + +연결 리스트와 마찬가지로 이진 트리에서도 포인터를 수정하여 노드를 삽입하고 삭제할 수 있다. 아래 그림(7-3)은 그 예다. + +![이진 트리의 노드 삽입과 삭제](binary_tree.assets/binary_tree_add_remove.png){ class="animation-figure" } + +

그림 7-3   이진 트리의 노드 삽입과 삭제

+ +=== "Python" + + ```python title="binary_tree.py" + # 노드 삽입과 삭제 + p = TreeNode(0) + # n1 -> n2 사이에 노드 P 삽입 + n1.left = p + p.left = n2 + # 노드 P 삭제 + n1.left = n2 + ``` + +=== "C++" + + ```cpp title="binary_tree.cpp" + /* 노드 삽입과 삭제 */ + TreeNode* P = new TreeNode(0); + // n1 -> n2 사이에 노드 P 삽입 + n1->left = P; + P->left = n2; + // 노드 P 삭제 + n1->left = n2; + // 메모리 해제 + delete P; + ``` + +=== "Java" + + ```java title="binary_tree.java" + TreeNode P = new TreeNode(0); + // n1 -> n2 사이에 노드 P 삽입 + n1.left = P; + P.left = n2; + // 노드 P 삭제 + n1.left = n2; + ``` + +=== "C#" + + ```csharp title="binary_tree.cs" + /* 노드 삽입과 삭제 */ + TreeNode P = new(0); + // n1 -> n2 사이에 노드 P 삽입 + n1.left = P; + P.left = n2; + // 노드 P 삭제 + n1.left = n2; + ``` + +=== "Go" + + ```go title="binary_tree.go" + /* 노드 삽입과 삭제 */ + // n1 -> n2 사이에 노드 P 삽입 + p := NewTreeNode(0) + n1.Left = p + p.Left = n2 + // 노드 P 삭제 + n1.Left = n2 + ``` + +=== "Swift" + + ```swift title="binary_tree.swift" + let P = TreeNode(x: 0) + // n1 -> n2 사이에 노드 P 삽입 + n1.left = P + P.left = n2 + // 노드 P 삭제 + n1.left = n2 + ``` + +=== "JS" + + ```javascript title="binary_tree.js" + /* 노드 삽입과 삭제 */ + let P = new TreeNode(0); + // n1 -> n2 사이에 노드 P 삽입 + n1.left = P; + P.left = n2; + // 노드 P 삭제 + n1.left = n2; + ``` + +=== "TS" + + ```typescript title="binary_tree.ts" + /* 노드 삽입과 삭제 */ + const P = new TreeNode(0); + // n1 -> n2 사이에 노드 P 삽입 + n1.left = P; + P.left = n2; + // 노드 P 삭제 + n1.left = n2; + ``` + +=== "Dart" + + ```dart title="binary_tree.dart" + /* 노드 삽입과 삭제 */ + TreeNode P = new TreeNode(0); + // n1 -> n2 사이에 노드 P 삽입 + n1.left = P; + P.left = n2; + // 노드 P 삭제 + n1.left = n2; + ``` + +=== "Rust" + + ```rust title="binary_tree.rs" + let p = TreeNode::new(0); + // n1 -> n2 사이에 노드 P 삽입 + n1.borrow_mut().left = Some(p.clone()); + p.borrow_mut().left = Some(n2.clone()); + // 노드 p 삭제 + n1.borrow_mut().left = Some(n2); + ``` + +=== "C" + + ```c title="binary_tree.c" + /* 노드 삽입과 삭제 */ + TreeNode *P = newTreeNode(0); + // n1 -> n2 사이에 노드 P 삽입 + n1->left = P; + P->left = n2; + // 노드 P 삭제 + n1->left = n2; + // 메모리 해제 + free(P); + ``` + +=== "Kotlin" + + ```kotlin title="binary_tree.kt" + val P = TreeNode(0) + // n1 -> n2 사이에 노드 P 삽입 + n1.left = P + P.left = n2 + // 노드 P 삭제 + n1.left = n2 + ``` + +=== "Ruby" + + ```ruby title="binary_tree.rb" + # 노드 삽입과 삭제 + _p = TreeNode.new(0) + # n1 -> n2 사이에 노드 _p 삽입 + n1.left = _p + _p.left = n2 + # 노드 삭제 + n1.left = n2 + ``` + +??? pythontutor "실행 시각화" + +
+ + +!!! tip "참고" + + 노드 삽입은 이진 트리의 기존 논리 구조를 바꿀 수 있으며, 노드 삭제는 보통 해당 노드와 그 모든 서브트리를 삭제한다는 뜻이다. 따라서 의미 있는 작업을 수행하려면 이진 트리의 삽입과 삭제는 보통 여러 연산을 조합하여 이루어진다. + +## 7.1.3   주요 이진 트리 종류 + +### 1.   포화 이진 트리 + +아래 그림(7-4)의 포화 이진 트리(perfect binary tree)는 모든 레벨에 노드가 가득 차 있다. 리프 노드의 차수는 $0$, 나머지 노드의 차수는 모두 $2$다. 높이가 $h$이면 전체 노드 수는 $2^{h+1} - 1$로 전형적인 지수 관계를 보이며, 자연에서 흔히 볼 수 있는 세포 분열 현상을 반영한다. + +!!! tip "참고" + + 중국어권에서는 포화 이진 트리를 흔히 满二叉树라고 부른다. + +![포화 이진 트리](binary_tree.assets/perfect_binary_tree.png){ class="animation-figure" } + +

그림 7-4   포화 이진 트리

+ +### 2.   완전 이진 트리 + +아래 그림(7-5)의 완전 이진 트리(complete binary tree)는 맨 아래 레벨만 가득 차 있지 않아도 되며, 그 레벨의 노드는 왼쪽부터 오른쪽으로 빈틈없이 채워져야 한다. 포화 이진 트리도 완전 이진 트리다. + +![완전 이진 트리](binary_tree.assets/complete_binary_tree.png){ class="animation-figure" } + +

그림 7-5   완전 이진 트리

+ +### 3.   정 이진 트리 + +아래 그림(7-6)의 정 이진 트리(full binary tree)는 리프를 제외한 모든 노드가 자식 노드를 두 개씩 가진다. + +![정 이진 트리](binary_tree.assets/full_binary_tree.png){ class="animation-figure" } + +

그림 7-6   정 이진 트리

+ +### 4.   균형 이진 트리 + +아래 그림(7-7)의 균형 이진 트리(balanced binary tree)는 모든 노드에서 왼쪽과 오른쪽 서브트리의 높이 차의 절댓값이 1을 넘지 않는다. + +![균형 이진 트리](binary_tree.assets/balanced_binary_tree.png){ class="animation-figure" } + +

그림 7-7   균형 이진 트리

+ +## 7.1.4   이진 트리의 퇴화 + +아래 그림(7-8)은 이진 트리의 이상적인 구조와 퇴화한 구조를 보여 준다. 모든 레벨이 가득 차면 '포화 이진 트리'가 되고, 모든 노드가 한쪽으로 치우치면 '연결 리스트'로 퇴화한다. + +- 포화 이진 트리는 이상적인 경우로, 이진 트리의 '분할 정복' 이점을 충분히 발휘할 수 있다. +- 연결 리스트는 다른 극단으로, 모든 연산이 선형 연산이 되어 시간 복잡도가 $O(n)$으로 저하된다. + +![이진 트리의 최선 구조와 최악 구조](binary_tree.assets/binary_tree_best_worst_cases.png){ class="animation-figure" } + +

그림 7-8   이진 트리의 최선 구조와 최악 구조

+ +아래 표(7-1)와 같이 최선 및 최악 구조에서 이진 트리의 리프 수, 전체 노드 수, 높이 등이 최댓값이나 최솟값에 도달한다. + +

표 7-1   이진 트리의 최선 구조와 최악 구조

+ +
+ +| | 포화 이진 트리 | 연결 리스트 | +| --------------------------- | ------------------ | ------- | +| $i$ 번째 레벨의 노드 수 | $2^{i-1}$ | $1$ | +| 높이 $h$인 트리의 리프 수 | $2^h$ | $1$ | +| 높이 $h$인 트리의 전체 노드 수 | $2^{h+1} - 1$ | $h + 1$ | +| 전체 노드 수가 $n$인 트리의 높이 | $\log_2 (n+1) - 1$ | $n - 1$ | + +
diff --git a/ko/docs/chapter_tree/binary_tree_traversal.md b/ko/docs/chapter_tree/binary_tree_traversal.md new file mode 100644 index 000000000..e234145e8 --- /dev/null +++ b/ko/docs/chapter_tree/binary_tree_traversal.md @@ -0,0 +1,874 @@ +--- +comments: true +--- + +# 7.2   이진 트리 순회 + +물리적 구조의 관점에서 트리는 연결 리스트 기반 자료 구조이므로, 포인터로 노드를 하나씩 방문하여 순회한다. 하지만 비선형 자료 구조여서 연결 리스트보다 순회가 복잡하며 탐색 알고리즘이 필요하다. + +이진 트리의 주요 순회 방식은 레벨 순서 순회, 전위 순회, 중위 순회, 후위 순회 등이다. + +## 7.2.1   레벨 순서 순회 + +아래 그림(7-9)과 같이 레벨 순서 순회(level-order traversal)는 위에서 아래로 한 레벨씩 순회하며, 각 레벨에서는 왼쪽부터 오른쪽 순서로 노드를 방문한다. + +레벨 순서 순회는 본질적으로 너비 우선 순회(breadth-first traversal), 즉 너비 우선 탐색(breadth-first search, BFS)에 속한다. '한 겹씩 바깥으로 확장'하는 층별 순회 방식이다. + +![이진 트리의 레벨 순서 순회](binary_tree_traversal.assets/binary_tree_bfs.png){ class="animation-figure" } + +

그림 7-9   이진 트리의 레벨 순서 순회

+ +### 1.   코드 구현 + +너비 우선 순회는 보통 '큐'로 구현한다. 큐는 '선입선출' 규칙을, 너비 우선 순회는 '한 레벨씩 진행'하는 규칙을 따르며, 바탕에 깔린 생각은 같다. 구현 코드는 다음과 같다. + +=== "Python" + + ```python title="binary_tree_bfs.py" + def level_order(root: TreeNode | None) -> list[int]: + """레벨 순서 순회""" + # 큐를 초기화하고 루트 노드를 넣는다 + queue: deque[TreeNode] = deque() + queue.append(root) + # 순회 순서를 저장할 리스트를 초기화한다 + res = [] + while queue: + node: TreeNode = queue.popleft() # 큐에서 꺼낸다 + res.append(node.val) # 노드 값을 저장한다 + if node.left is not None: + queue.append(node.left) # 왼쪽 자식 노드를 큐에 삽입한다 + if node.right is not None: + queue.append(node.right) # 오른쪽 자식 노드를 큐에 삽입한다 + return res + ``` + +=== "C++" + + ```cpp title="binary_tree_bfs.cpp" + /* 레벨 순서 순회 */ + vector levelOrder(TreeNode *root) { + // 큐를 초기화하고 루트 노드를 넣는다 + queue queue; + queue.push(root); + // 순회 순서를 저장할 리스트를 초기화한다 + vector vec; + while (!queue.empty()) { + TreeNode *node = queue.front(); + queue.pop(); // 큐에서 꺼낸다 + vec.push_back(node->val); // 노드 값을 저장한다 + if (node->left != nullptr) + queue.push(node->left); // 왼쪽 자식 노드를 큐에 삽입한다 + if (node->right != nullptr) + queue.push(node->right); // 오른쪽 자식 노드를 큐에 삽입한다 + } + return vec; + } + ``` + +=== "Java" + + ```java title="binary_tree_bfs.java" + /* 레벨 순서 순회 */ + List levelOrder(TreeNode root) { + // 큐를 초기화하고 루트 노드를 넣는다 + Queue queue = new LinkedList<>(); + queue.add(root); + // 순회 순서를 저장할 리스트를 초기화한다 + List list = new ArrayList<>(); + while (!queue.isEmpty()) { + TreeNode node = queue.poll(); // 큐에서 꺼낸다 + list.add(node.val); // 노드 값을 저장한다 + if (node.left != null) + queue.offer(node.left); // 왼쪽 자식 노드를 큐에 삽입한다 + if (node.right != null) + queue.offer(node.right); // 오른쪽 자식 노드를 큐에 삽입한다 + } + return list; + } + ``` + +=== "C#" + + ```csharp title="binary_tree_bfs.cs" + /* 레벨 순서 순회 */ + List LevelOrder(TreeNode root) { + // 큐를 초기화하고 루트 노드를 넣는다 + Queue queue = new(); + queue.Enqueue(root); + // 순회 순서를 저장할 리스트를 초기화한다 + List list = []; + while (queue.Count != 0) { + TreeNode node = queue.Dequeue(); // 큐에서 꺼낸다 + list.Add(node.val!.Value); // 노드 값을 저장한다 + if (node.left != null) + queue.Enqueue(node.left); // 왼쪽 자식 노드를 큐에 삽입한다 + if (node.right != null) + queue.Enqueue(node.right); // 오른쪽 자식 노드를 큐에 삽입한다 + } + return list; + } + ``` + +=== "Go" + + ```go title="binary_tree_bfs.go" + /* 레벨 순서 순회 */ + func levelOrder(root *TreeNode) []any { + // 큐를 초기화하고 루트 노드를 넣는다 + queue := list.New() + queue.PushBack(root) + // 순회 순서를 저장할 슬라이스를 초기화한다 + nums := make([]any, 0) + for queue.Len() > 0 { + // 큐에서 꺼낸다 + node := queue.Remove(queue.Front()).(*TreeNode) + // 노드 값을 저장한다 + nums = append(nums, node.Val) + if node.Left != nil { + // 왼쪽 자식 노드를 큐에 삽입한다 + queue.PushBack(node.Left) + } + if node.Right != nil { + // 오른쪽 자식 노드를 큐에 삽입한다 + queue.PushBack(node.Right) + } + } + return nums + } + ``` + +=== "Swift" + + ```swift title="binary_tree_bfs.swift" + /* 레벨 순서 순회 */ + func levelOrder(root: TreeNode) -> [Int] { + // 큐를 초기화하고 루트 노드를 넣는다 + var queue: [TreeNode] = [root] + // 순회 순서를 저장할 리스트를 초기화한다 + var list: [Int] = [] + while !queue.isEmpty { + let node = queue.removeFirst() // 큐에서 꺼낸다 + list.append(node.val) // 노드 값을 저장한다 + if let left = node.left { + queue.append(left) // 왼쪽 자식 노드를 큐에 삽입한다 + } + if let right = node.right { + queue.append(right) // 오른쪽 자식 노드를 큐에 삽입한다 + } + } + return list + } + ``` + +=== "JS" + + ```javascript title="binary_tree_bfs.js" + /* 레벨 순서 순회 */ + function levelOrder(root) { + // 큐를 초기화하고 루트 노드를 넣는다 + const queue = [root]; + // 순회 순서를 저장할 리스트를 초기화한다 + const list = []; + while (queue.length) { + let node = queue.shift(); // 큐에서 꺼낸다 + list.push(node.val); // 노드 값을 저장한다 + if (node.left) queue.push(node.left); // 왼쪽 자식 노드를 큐에 삽입한다 + if (node.right) queue.push(node.right); // 오른쪽 자식 노드를 큐에 삽입한다 + } + return list; + } + ``` + +=== "TS" + + ```typescript title="binary_tree_bfs.ts" + /* 레벨 순서 순회 */ + function levelOrder(root: TreeNode | null): number[] { + // 큐를 초기화하고 루트 노드를 넣는다 + const queue = [root]; + // 순회 순서를 저장할 리스트를 초기화한다 + const list: number[] = []; + while (queue.length) { + let node = queue.shift() as TreeNode; // 큐에서 꺼낸다 + list.push(node.val); // 노드 값을 저장한다 + if (node.left) { + queue.push(node.left); // 왼쪽 자식 노드를 큐에 삽입한다 + } + if (node.right) { + queue.push(node.right); // 오른쪽 자식 노드를 큐에 삽입한다 + } + } + return list; + } + ``` + +=== "Dart" + + ```dart title="binary_tree_bfs.dart" + /* 레벨 순서 순회 */ + List levelOrder(TreeNode? root) { + // 큐를 초기화하고 루트 노드를 넣는다 + Queue queue = Queue(); + queue.add(root); + // 순회 순서를 저장할 리스트를 초기화한다 + List res = []; + while (queue.isNotEmpty) { + TreeNode? node = queue.removeFirst(); // 큐에서 꺼낸다 + res.add(node!.val); // 노드 값을 저장한다 + if (node.left != null) queue.add(node.left); // 왼쪽 자식 노드를 큐에 삽입한다 + if (node.right != null) queue.add(node.right); // 오른쪽 자식 노드를 큐에 삽입한다 + } + return res; + } + ``` + +=== "Rust" + + ```rust title="binary_tree_bfs.rs" + /* 레벨 순서 순회 */ + fn level_order(root: &Rc>) -> Vec { + // 큐를 초기화하고 루트 노드를 넣는다 + let mut que = VecDeque::new(); + que.push_back(root.clone()); + // 순회 순서를 저장할 리스트를 초기화한다 + let mut vec = Vec::new(); + + while let Some(node) = que.pop_front() { + // 큐에서 꺼낸다 + vec.push(node.borrow().val); // 노드 값을 저장한다 + if let Some(left) = node.borrow().left.as_ref() { + que.push_back(left.clone()); // 왼쪽 자식 노드를 큐에 삽입한다 + } + if let Some(right) = node.borrow().right.as_ref() { + que.push_back(right.clone()); // 오른쪽 자식 노드를 큐에 삽입한다 + }; + } + vec + } + ``` + +=== "C" + + ```c title="binary_tree_bfs.c" + /* 레벨 순서 순회 */ + int *levelOrder(TreeNode *root, int *size) { + /* 보조 큐 */ + int front, rear; + int index, *arr; + TreeNode *node; + TreeNode **queue; + + /* 보조 큐 */ + queue = (TreeNode **)malloc(sizeof(TreeNode *) * MAX_SIZE); + // 큐 포인터 + front = 0, rear = 0; + // 루트 노드를 넣는다 + queue[rear++] = root; + // 순회 순서를 저장할 리스트를 초기화한다 + /* 보조 배열 */ + arr = (int *)malloc(sizeof(int) * MAX_SIZE); + // 배열 포인터 + index = 0; + while (front < rear) { + // 큐에서 꺼낸다 + node = queue[front++]; + // 노드 값을 저장한다 + arr[index++] = node->val; + if (node->left != NULL) { + // 왼쪽 자식 노드를 큐에 삽입한다 + queue[rear++] = node->left; + } + if (node->right != NULL) { + // 오른쪽 자식 노드를 큐에 삽입한다 + queue[rear++] = node->right; + } + } + // 배열 길이 값을 갱신한다 + *size = index; + arr = realloc(arr, sizeof(int) * (*size)); + + // 보조 배열의 메모리 공간을 해제한다 + free(queue); + return arr; + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_tree_bfs.kt" + /* 레벨 순서 순회 */ + fun levelOrder(root: TreeNode?): MutableList { + // 큐를 초기화하고 루트 노드를 넣는다 + val queue = LinkedList() + queue.add(root) + // 순회 순서를 저장할 리스트를 초기화한다 + val list = mutableListOf() + while (queue.isNotEmpty()) { + val node = queue.poll() // 큐에서 꺼낸다 + list.add(node?._val!!) // 노드 값을 저장한다 + if (node.left != null) + queue.offer(node.left) // 왼쪽 자식 노드를 큐에 삽입한다 + if (node.right != null) + queue.offer(node.right) // 오른쪽 자식 노드를 큐에 삽입한다 + } + return list + } + ``` + +=== "Ruby" + + ```ruby title="binary_tree_bfs.rb" + ### 레벨 순서 순회 ### + def level_order(root) + # 큐를 초기화하고 루트 노드를 넣는다 + queue = [root] + # 순회 순서를 저장할 리스트를 초기화한다 + res = [] + while !queue.empty? + node = queue.shift # 큐에서 꺼낸다 + res << node.val # 노드 값을 저장한다 + queue << node.left unless node.left.nil? # 왼쪽 자식 노드를 큐에 삽입한다 + queue << node.right unless node.right.nil? # 오른쪽 자식 노드를 큐에 삽입한다 + end + res + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +### 2.   복잡도 분석 + +- **시간 복잡도 $O(n)$**: 모든 노드를 한 번씩 방문하므로 $O(n)$ 시간이 걸린다. $n$은 노드 수다. +- **공간 복잡도 $O(n)$**: 최악의 경우인 포화 이진 트리에서는 맨 아래 레벨을 순회하기 직전에 큐에 최대 $(n + 1) / 2$ 개 노드가 동시에 들어 있어 $O(n)$ 공간을 차지한다. + +## 7.2.2   전위, 중위, 후위 순회 + +전위, 중위, 후위 순회는 모두 깊이 우선 순회(depth-first traversal), 즉 깊이 우선 탐색(depth-first search, DFS)에 속한다. '먼저 끝까지 간 뒤 돌아와 계속하는' 순회 방식이다. + +아래 그림(7-10)은 이진 트리의 깊이 우선 순회 원리를 보여 준다. **깊이 우선 순회는 트리 전체의 바깥을 한 바퀴 '도는' 것과 같다**. 각 노드에서 세 위치를 만나며, 각각 전위, 중위, 후위 순회에 대응한다. + +![이진 탐색 트리의 전위, 중위, 후위 순회](binary_tree_traversal.assets/binary_tree_dfs.png){ class="animation-figure" } + +

그림 7-10   이진 탐색 트리의 전위, 중위, 후위 순회

+ +### 1.   코드 구현 + +깊이 우선 탐색은 보통 재귀로 구현한다. + +=== "Python" + + ```python title="binary_tree_dfs.py" + def pre_order(root: TreeNode | None): + """전위 순회""" + if root is None: + return + # 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + res.append(root.val) + pre_order(root=root.left) + pre_order(root=root.right) + + def in_order(root: TreeNode | None): + """중위 순회""" + if root is None: + return + # 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + in_order(root=root.left) + res.append(root.val) + in_order(root=root.right) + + def post_order(root: TreeNode | None): + """후위 순회""" + if root is None: + return + # 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + post_order(root=root.left) + post_order(root=root.right) + res.append(root.val) + ``` + +=== "C++" + + ```cpp title="binary_tree_dfs.cpp" + /* 전위 순회 */ + void preOrder(TreeNode *root) { + if (root == nullptr) + return; + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + vec.push_back(root->val); + preOrder(root->left); + preOrder(root->right); + } + + /* 중위 순회 */ + void inOrder(TreeNode *root) { + if (root == nullptr) + return; + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + inOrder(root->left); + vec.push_back(root->val); + inOrder(root->right); + } + + /* 후위 순회 */ + void postOrder(TreeNode *root) { + if (root == nullptr) + return; + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + postOrder(root->left); + postOrder(root->right); + vec.push_back(root->val); + } + ``` + +=== "Java" + + ```java title="binary_tree_dfs.java" + /* 전위 순회 */ + void preOrder(TreeNode root) { + if (root == null) + return; + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + list.add(root.val); + preOrder(root.left); + preOrder(root.right); + } + + /* 중위 순회 */ + void inOrder(TreeNode root) { + if (root == null) + return; + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + inOrder(root.left); + list.add(root.val); + inOrder(root.right); + } + + /* 후위 순회 */ + void postOrder(TreeNode root) { + if (root == null) + return; + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + postOrder(root.left); + postOrder(root.right); + list.add(root.val); + } + ``` + +=== "C#" + + ```csharp title="binary_tree_dfs.cs" + /* 전위 순회 */ + void PreOrder(TreeNode? root) { + if (root == null) return; + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + list.Add(root.val!.Value); + PreOrder(root.left); + PreOrder(root.right); + } + + /* 중위 순회 */ + void InOrder(TreeNode? root) { + if (root == null) return; + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + InOrder(root.left); + list.Add(root.val!.Value); + InOrder(root.right); + } + + /* 후위 순회 */ + void PostOrder(TreeNode? root) { + if (root == null) return; + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + PostOrder(root.left); + PostOrder(root.right); + list.Add(root.val!.Value); + } + ``` + +=== "Go" + + ```go title="binary_tree_dfs.go" + /* 전위 순회 */ + func preOrder(node *TreeNode) { + if node == nil { + return + } + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + nums = append(nums, node.Val) + preOrder(node.Left) + preOrder(node.Right) + } + + /* 중위 순회 */ + func inOrder(node *TreeNode) { + if node == nil { + return + } + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + inOrder(node.Left) + nums = append(nums, node.Val) + inOrder(node.Right) + } + + /* 후위 순회 */ + func postOrder(node *TreeNode) { + if node == nil { + return + } + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + postOrder(node.Left) + postOrder(node.Right) + nums = append(nums, node.Val) + } + ``` + +=== "Swift" + + ```swift title="binary_tree_dfs.swift" + /* 전위 순회 */ + func preOrder(root: TreeNode?) { + guard let root = root else { + return + } + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + list.append(root.val) + preOrder(root: root.left) + preOrder(root: root.right) + } + + /* 중위 순회 */ + func inOrder(root: TreeNode?) { + guard let root = root else { + return + } + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + inOrder(root: root.left) + list.append(root.val) + inOrder(root: root.right) + } + + /* 후위 순회 */ + func postOrder(root: TreeNode?) { + guard let root = root else { + return + } + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + postOrder(root: root.left) + postOrder(root: root.right) + list.append(root.val) + } + ``` + +=== "JS" + + ```javascript title="binary_tree_dfs.js" + /* 전위 순회 */ + function preOrder(root) { + if (root === null) return; + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + list.push(root.val); + preOrder(root.left); + preOrder(root.right); + } + + /* 중위 순회 */ + function inOrder(root) { + if (root === null) return; + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + inOrder(root.left); + list.push(root.val); + inOrder(root.right); + } + + /* 후위 순회 */ + function postOrder(root) { + if (root === null) return; + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + postOrder(root.left); + postOrder(root.right); + list.push(root.val); + } + ``` + +=== "TS" + + ```typescript title="binary_tree_dfs.ts" + /* 전위 순회 */ + function preOrder(root: TreeNode | null): void { + if (root === null) { + return; + } + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + list.push(root.val); + preOrder(root.left); + preOrder(root.right); + } + + /* 중위 순회 */ + function inOrder(root: TreeNode | null): void { + if (root === null) { + return; + } + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + inOrder(root.left); + list.push(root.val); + inOrder(root.right); + } + + /* 후위 순회 */ + function postOrder(root: TreeNode | null): void { + if (root === null) { + return; + } + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + postOrder(root.left); + postOrder(root.right); + list.push(root.val); + } + ``` + +=== "Dart" + + ```dart title="binary_tree_dfs.dart" + /* 전위 순회 */ + void preOrder(TreeNode? node) { + if (node == null) return; + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + list.add(node.val); + preOrder(node.left); + preOrder(node.right); + } + + /* 중위 순회 */ + void inOrder(TreeNode? node) { + if (node == null) return; + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + inOrder(node.left); + list.add(node.val); + inOrder(node.right); + } + + /* 후위 순회 */ + void postOrder(TreeNode? node) { + if (node == null) return; + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + postOrder(node.left); + postOrder(node.right); + list.add(node.val); + } + ``` + +=== "Rust" + + ```rust title="binary_tree_dfs.rs" + /* 전위 순회 */ + fn pre_order(root: Option<&Rc>>) -> Vec { + let mut result = vec![]; + + fn dfs(root: Option<&Rc>>, res: &mut Vec) { + if let Some(node) = root { + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + let node = node.borrow(); + res.push(node.val); + dfs(node.left.as_ref(), res); + dfs(node.right.as_ref(), res); + } + } + dfs(root, &mut result); + + result + } + + /* 중위 순회 */ + fn in_order(root: Option<&Rc>>) -> Vec { + let mut result = vec![]; + + fn dfs(root: Option<&Rc>>, res: &mut Vec) { + if let Some(node) = root { + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + let node = node.borrow(); + dfs(node.left.as_ref(), res); + res.push(node.val); + dfs(node.right.as_ref(), res); + } + } + dfs(root, &mut result); + + result + } + + /* 후위 순회 */ + fn post_order(root: Option<&Rc>>) -> Vec { + let mut result = vec![]; + + fn dfs(root: Option<&Rc>>, res: &mut Vec) { + if let Some(node) = root { + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + let node = node.borrow(); + dfs(node.left.as_ref(), res); + dfs(node.right.as_ref(), res); + res.push(node.val); + } + } + + dfs(root, &mut result); + + result + } + ``` + +=== "C" + + ```c title="binary_tree_dfs.c" + /* 전위 순회 */ + void preOrder(TreeNode *root, int *size) { + if (root == NULL) + return; + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + arr[(*size)++] = root->val; + preOrder(root->left, size); + preOrder(root->right, size); + } + + /* 중위 순회 */ + void inOrder(TreeNode *root, int *size) { + if (root == NULL) + return; + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + inOrder(root->left, size); + arr[(*size)++] = root->val; + inOrder(root->right, size); + } + + /* 후위 순회 */ + void postOrder(TreeNode *root, int *size) { + if (root == NULL) + return; + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + postOrder(root->left, size); + postOrder(root->right, size); + arr[(*size)++] = root->val; + } + ``` + +=== "Kotlin" + + ```kotlin title="binary_tree_dfs.kt" + /* 전위 순회 */ + fun preOrder(root: TreeNode?) { + if (root == null) return + // 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + list.add(root._val) + preOrder(root.left) + preOrder(root.right) + } + + /* 중위 순회 */ + fun inOrder(root: TreeNode?) { + if (root == null) return + // 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + inOrder(root.left) + list.add(root._val) + inOrder(root.right) + } + + /* 후위 순회 */ + fun postOrder(root: TreeNode?) { + if (root == null) return + // 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + postOrder(root.left) + postOrder(root.right) + list.add(root._val) + } + ``` + +=== "Ruby" + + ```ruby title="binary_tree_dfs.rb" + ### 전위 순회 ### + def pre_order(root) + return if root.nil? + + # 방문 우선순위: 루트 노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 + $res << root.val + pre_order(root.left) + pre_order(root.right) + end + + ### 중위 순회 ### + def in_order(root) + return if root.nil? + + # 방문 우선순위: 왼쪽 서브트리 -> 루트 노드 -> 오른쪽 서브트리 + in_order(root.left) + $res << root.val + in_order(root.right) + end + + ### 후위 순회 ### + def post_order(root) + return if root.nil? + + # 방문 우선순위: 왼쪽 서브트리 -> 오른쪽 서브트리 -> 루트 노드 + post_order(root.left) + post_order(root.right) + $res << root.val + end + ``` + +??? pythontutor "코드 시각화" + +
+ + +!!! tip "참고" + + 깊이 우선 탐색은 반복으로도 구현할 수 있다. 관심 있는 독자는 직접 알아보자. + +아래 그림(7-11)은 이진 트리 전위 순회의 재귀 과정을 보여 준다. 서로 반대 방향인 '호출'과 '반환'으로 나뉜다. + +1. '호출'은 새 메서드를 시작하는 것으로, 프로그램은 이 과정에서 다음 노드를 방문한다. +2. '반환'은 함수가 돌아오는 것으로, 현재 노드의 방문이 끝났음을 뜻한다. + +=== "<1>" + ![전위 순회의 재귀 과정](binary_tree_traversal.assets/preorder_step1.png){ class="animation-figure" } + +=== "<2>" + ![전위 순회 2단계](binary_tree_traversal.assets/preorder_step2.png){ class="animation-figure" } + +=== "<3>" + ![전위 순회 3단계](binary_tree_traversal.assets/preorder_step3.png){ class="animation-figure" } + +=== "<4>" + ![전위 순회 4단계](binary_tree_traversal.assets/preorder_step4.png){ class="animation-figure" } + +=== "<5>" + ![전위 순회 5단계](binary_tree_traversal.assets/preorder_step5.png){ class="animation-figure" } + +=== "<6>" + ![전위 순회 6단계](binary_tree_traversal.assets/preorder_step6.png){ class="animation-figure" } + +=== "<7>" + ![전위 순회 7단계](binary_tree_traversal.assets/preorder_step7.png){ class="animation-figure" } + +=== "<8>" + ![전위 순회 8단계](binary_tree_traversal.assets/preorder_step8.png){ class="animation-figure" } + +=== "<9>" + ![전위 순회 9단계](binary_tree_traversal.assets/preorder_step9.png){ class="animation-figure" } + +=== "<10>" + ![전위 순회 10단계](binary_tree_traversal.assets/preorder_step10.png){ class="animation-figure" } + +=== "<11>" + ![전위 순회 11단계](binary_tree_traversal.assets/preorder_step11.png){ class="animation-figure" } + +

그림 7-11   전위 순회의 재귀 과정

+ +### 2.   복잡도 분석 + +- **시간 복잡도 $O(n)$**: 모든 노드를 한 번씩 방문하므로 $O(n)$ 시간이 걸린다. +- **공간 복잡도 $O(n)$**: 최악의 경우 트리가 연결 리스트로 퇴화하면 재귀 깊이가 $n$에 이르러 시스템이 $O(n)$의 스택 프레임 공간을 사용한다. diff --git a/ko/docs/chapter_tree/exercises.md b/ko/docs/chapter_tree/exercises.md new file mode 100644 index 000000000..7eb67ce40 --- /dev/null +++ b/ko/docs/chapter_tree/exercises.md @@ -0,0 +1,130 @@ +--- +comments: true +--- + +# 7.7   연습 문제 + +## 7.7.1   개념 확인 + +### 1.   완전 이진 트리, 정 이진 트리, 포화 이진 트리 + +다음 두 배열은 이진 트리를 레벨 순서로 나타낸 것이다. `None`은 빈자리를 뜻한다. + +- 트리 A: `[1, 2, 3, 4, 5, 6]` +- 트리 B: `[1, 2, 3, None, None, 6, 7]` + + + +1. 어느 트리가 완전 이진 트리인가? +2. 어느 트리가 모든 비리프 노드에 자식이 두 개씩 있는 정 이진 트리인가? +3. 두 트리 중 포화 이진 트리가 있는가? 각각 이유를 설명하라. + +??? success "참고 답안" + + 1. 트리 A는 완전 이진 트리다. 맨 아래 레벨만 가득 차지 않았고, 노드가 왼쪽부터 연속해서 배치되어 있다. + 트리 B는 맨 아래 레벨의 왼쪽에 빈자리가 있는데 오른쪽에 노드가 있으므로 완전 이진 트리가 아니다. + + 2. 트리 B는 정 이진 트리다. 노드 1과 노드 3은 자식이 각각 두 개이고, 나머지는 모두 리프 노드다. + 트리 A의 노드 3에는 왼쪽 자식 6만 있으므로 정 이진 트리가 아니다. + + 3. 두 트리 모두 맨 아래 레벨이 완전히 채워져 있지 않으므로 포화 이진 트리가 아니다. + +### 2.   같은 트리의 세 가지 순회 순서 + +배열 `[1, 2, 3, 4, 5, 6, 7]`을 레벨 순서로 완전 이진 트리에 저장한다. + + + +1. 이 트리를 그려 보자. +2. 전위, 중위, 후위 순회 결과를 각각 적어 보자. +3. 중위 순회 결과에서 루트 노드 1의 왼쪽과 오른쪽에 있는 두 수열은 각각 트리의 어느 부분에 대응하는가? + +??? success "참고 답안" + + 1. 트리는 다음과 같다. + + ```text + 1 + / \ + 2 3 + / \ / \ + 4 5 6 7 + ``` + + 2. 전위 순회는 `1, 2, 4, 5, 3, 6, 7`이다. + 중위 순회는 `4, 2, 5, 1, 6, 3, 7`이다. + 후위 순회는 `4, 5, 2, 6, 7, 3, 1`이다. + + 3. 루트 노드 1의 왼쪽에 있는 `4, 2, 5`는 왼쪽 서브트리의 중위 순회 결과다. + 오른쪽의 `6, 3, 7`은 오른쪽 서브트리의 중위 순회 결과다. + +### 3.   두 이진 탐색 트리 비교하기 + +다음 두 수열을 각각 왼쪽부터 빈 이진 탐색 트리에 차례대로 삽입한다. + +- 수열 A: `[4, 2, 6, 1, 3, 5, 7]` +- 수열 B: `[1, 2, 3, 4, 5, 6, 7]` + + + +1. 숫자 7을 찾을 때 거치는 노드를 각각 적어 보자. +2. 높이를 루트에서 가장 먼 리프까지 지나는 간선 수로 계산할 때, 두 트리의 높이는 각각 얼마인가? +3. 앞선 두 질문을 바탕으로 두 트리에서 7을 찾는 효율성이 같은지 판단하라. 트리의 모양과 탐색 경로를 바탕으로 이유를 설명하라. + +??? success "참고 답안" + + 1. 수열 A로 만든 트리의 탐색 경로는 `4 → 6 → 7`이다. + 수열 B로 만든 트리의 탐색 경로는 `1 → 2 → 3 → 4 → 5 → 6 → 7`이다. + + 2. 첫 번째 트리는 모든 레벨이 가득 차 있고 높이는 2다. 두 번째 트리는 오른쪽 자식만 있으며 높이는 6이다. + + 3. 두 트리에서 7을 찾는 효율성은 다르다. 삽입 순서가 이진 탐색 트리의 모양과 높이를 바꾸기 때문이다. 첫 번째 트리는 노드 3개만 거치지만, + 두 번째 트리는 노드 7개를 차례대로 거친다. 트리가 높을수록 최악의 경우 경로를 따라 비교해야 하는 노드가 많아진다. + +## 7.7.2   프로그래밍 연습 + +### 1.   이진 트리의 최대 깊이 + +이진 트리의 루트 노드 `root`가 주어진다. 각 노드는 정숫값과 왼쪽 및 오른쪽 자식 노드에 대한 참조를 가진다. + +루트에서 가장 먼 리프까지 거치는 **노드 수**를 이진 트리의 최대 깊이라고 정의한다. 이 트리의 최대 깊이를 반환하라. 빈 트리의 최대 깊이는 0이다. +재귀를 사용하여 해결하라. + +??? tip "풀이 힌트" + + 1. 이 문제에서는 노드 수로 깊이를 계산한다. 루트 하나만 있는 트리의 최대 깊이는 1이다. + 2. 재귀 함수가 현재 노드를 루트로 하는 서브트리의 최대 깊이를 반환하도록 한다. + 3. 빈 노드는 0을 반환하고, 비어 있지 않은 노드는 max(depth(left), depth(right)) + 1을 반환한다. + +[LeetCode](https://leetcode.com/problems/maximum-depth-of-binary-tree/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } + +### 2.   이진 트리를 레벨 순서로 순회하기 + +이진 트리의 루트 노드 `root`가 주어진다. 큐를 사용하여 위에서 아래로 한 레벨씩 모든 노드를 방문하라. 같은 레벨에서는 왼쪽부터 오른쪽으로 방문한다. + +2차원 배열을 반환하라. 첫 번째 하위 배열에는 루트가 있는 레벨의 노드 값을, 두 번째에는 다음 레벨의 노드 값을 저장하며 이 방식을 반복한다. +빈 트리라면 빈 배열을 반환하라. + +??? tip "풀이 힌트" + + 1. 레벨 순서 순회에서는 먼저 들어온 노드를 먼저 방문해야 하므로 큐를 사용한다. + 2. 각 반복이 시작될 때 큐에 있는 노드들은 정확히 같은 레벨에 속한다. + 3. 큐의 길이를 먼저 기록한 뒤 그 수만큼 노드를 꺼내고 각 노드의 자식을 넣는다. + +[LeetCode](https://leetcode.com/problems/binary-tree-level-order-traversal/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } + +### 3.   이진 탐색 트리에서 k번째로 작은 원소 + +이진 탐색 트리에 총 `n`개의 노드가 있으며, 노드 값은 모두 다르다. +모든 노드 값을 작은 것부터 나열하고 위치에 1부터 번호를 붙인다. + +루트 노드 `root`와 `1 <= k <= n`을 만족하는 정수 `k`가 주어질 때, `k`번째 노드 값을 반환하라. +모든 값을 미리 모으지 말고 중위 순회 도중에 바로 답을 찾아라. + +??? tip "풀이 힌트" + + 1. 이진 탐색 트리를 중위 순회하면 노드 값을 작은 것부터 방문한다. + 2. 중위 순회는 왼쪽 서브트리, 현재 노드, 오른쪽 서브트리 순서로 처리한다. 현재 노드를 방문할 때 개수를 1 늘린다. + 3. 개수가 처음 k가 되었을 때의 노드 값이 답이다. 이후에는 순회를 계속할 필요가 없다. + +[LeetCode](https://leetcode.com/problems/kth-smallest-element-in-a-bst/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } diff --git a/ko/docs/chapter_tree/index.md b/ko/docs/chapter_tree/index.md new file mode 100644 index 000000000..d1253c99d --- /dev/null +++ b/ko/docs/chapter_tree/index.md @@ -0,0 +1,24 @@ +--- +comments: true +icon: material/graph-outline +--- + +# 제 7 장   트리 + +![트리](../assets/covers/chapter_tree.jpg){ class="cover-image" } + +!!! abstract "이 장을 시작하며" + + 하늘 높이 뻗은 나무는 깊은 뿌리와 무성한 잎, 넓게 펼쳐진 가지에 생명력이 가득하다. + + 나무는 데이터를 나누어 다루는 모습을 생생하게 보여 준다. + +## 이 장의 내용 + +- [7.1   이진 트리](binary_tree.md) +- [7.2   이진 트리 순회](binary_tree_traversal.md) +- [7.3   이진 트리의 배열 표현](array_representation_of_tree.md) +- [7.4   이진 탐색 트리](binary_search_tree.md) +- [7.5   AVL 트리 *](avl_tree.md) +- [7.6   요약](summary.md) +- [7.7   연습 문제](exercises.md) diff --git a/ko/docs/chapter_tree/summary.md b/ko/docs/chapter_tree/summary.md new file mode 100644 index 000000000..7453c3329 --- /dev/null +++ b/ko/docs/chapter_tree/summary.md @@ -0,0 +1,58 @@ +--- +comments: true +--- + +# 7.6   요약 + +### 1.   핵심 정리 + +- 이진 트리는 '하나를 둘로 나누는' 분할 정복 논리를 보여 주는 비선형 자료 구조다. 각 노드는 값 하나와 왼쪽, 오른쪽 자식을 가리키는 포인터 두 개를 포함한다. +- 어떤 노드의 왼쪽(오른쪽) 자식과 그 아래 노드들이 이루는 트리를 해당 노드의 왼쪽(오른쪽) 서브트리라고 한다. +- 주요 용어에는 루트 노드, 리프 노드, 레벨, 차수, 간선, 높이, 깊이 등이 있다. +- 이진 트리의 초기화, 노드 삽입과 삭제 방법은 연결 리스트와 비슷하다. +- 주요 종류는 포화 이진 트리, 완전 이진 트리, 정 이진 트리, 균형 이진 트리다. 포화 이진 트리가 가장 이상적이며, 연결 리스트는 퇴화한 최악의 상태다. +- 이진 트리는 노드 값과 빈자리를 레벨 순서대로 배열하여 배열로 표현할 수 있다. 부모와 자식의 인덱스 매핑 관계가 포인터를 대신한다. +- 레벨 순서 순회는 '한 겹씩 바깥으로 확장'하는 너비 우선 탐색이며, 보통 큐로 구현한다. +- 전위, 중위, 후위 순회는 '먼저 끝까지 간 뒤 돌아와 계속하는' 깊이 우선 탐색이며, 보통 재귀로 구현한다. +- 이진 탐색 트리는 효율적인 원소 탐색 자료 구조로, 탐색, 삽입, 삭제의 시간 복잡도는 모두 $O(\log n)$이다. 연결 리스트로 퇴화하면 각 복잡도가 $O(n)$으로 저하된다. +- AVL 트리는 균형 이진 탐색 트리라고도 하며, 회전 연산으로 삽입과 삭제가 반복되어도 균형을 유지한다. +- AVL 트리의 회전에는 오른쪽 회전, 왼쪽 회전, 오른쪽 후 왼쪽 회전, 왼쪽 후 오른쪽 회전이 있다. 삽입이나 삭제 후 아래에서 위로 회전하여 균형을 복구한다. + +### 2.   Q & A + +**Q**: 노드가 하나뿐인 이진 트리의 높이와 루트의 깊이는 모두 $0$인가? + +그렇다. 높이와 깊이는 보통 '거치는 간선 수'로 정의하기 때문이다. + +**Q**: 이진 트리의 삽입과 삭제를 보통 여러 연산의 조합으로 수행한다고 했는데, 여기서 '여러 연산'은 무엇인가? 해당 자원에 속한 자식 노드의 자원을 해제하는 것으로 이해해도 되는가? + +이진 탐색 트리를 예로 들면 삭제를 세 경우로 나누어 처리하며, 각 경우마다 여러 단계의 노드 연산이 필요하다. + +**Q**: 이진 트리의 DFS에는 왜 전위, 중위, 후위라는 세 순서가 있고 각각 어디에 쓰이는가? + +배열을 정방향이나 역방향으로 순회하듯이 전위, 중위, 후위는 이진 트리를 순회하는 세 방법이며 특정 순서의 결과를 얻는 데 사용한다. 예를 들어 이진 탐색 트리는 `왼쪽 자식 값 < 루트 값 < 오른쪽 자식 값`을 만족하므로 '왼쪽 $\rightarrow$ 루트 $\rightarrow$ 오른쪽' 우선순위로 순회하면 정렬된 노드 순서를 얻는다. + +**Q**: 오른쪽 회전은 불균형 노드 `node`, `child`, `grand_child` 사이의 관계를 처리하는데, `node`와 그 부모의 기존 연결은 관리하지 않아도 되는가? 회전 후 끊어지지 않는가? + +재귀의 관점에서 보아야 한다. `right_rotate(root)`는 서브트리의 루트를 전달받고, 마지막의 `return child`는 회전 후의 서브트리 루트를 반환한다. 새 서브트리 루트와 부모의 연결은 함수가 반환된 뒤 이루어지므로 오른쪽 회전 자체가 관리할 범위가 아니다. + +**Q**: C++에서 함수를 `private`와 `public`으로 나누는 기준은 무엇인가? `height()`는 `public`, `updateHeight()`는 `private`에 두는 이유는 무엇인가? + +주로 메서드의 사용 범위를 기준으로 한다. 클래스 내부에서만 쓰면 `private`로 설계한다. 예를 들어 `updateHeight()`는 삽입과 삭제의 한 단계일 뿐이라 사용자가 따로 호출할 의미가 없다. 반면 `height()`는 `vector.size()`처럼 노드 높이에 접근하는 기능이므로 편리하게 쓰도록 `public`으로 둔다. + +**Q**: 입력 데이터 집합에서 이진 탐색 트리를 어떻게 구성하는가? 루트 선택이 중요한가? + +그렇다. 구성 방법은 이진 탐색 트리 코드의 `build_tree()`에 제시되어 있다. 루트는 보통 입력을 정렬한 뒤 중간 원소로 정하고, 왼쪽과 오른쪽 서브트리를 재귀적으로 구성한다. 이렇게 하면 트리의 균형을 최대한 보장할 수 있다. + +**Q**: Java에서 문자열을 비교할 때 반드시 `equals()`를 사용해야 하는가? + +Java에서 기본 타입의 `==`는 두 변수의 값이 같은지 비교한다. 참조 타입에서는 두 방식의 원리가 다르다. + +- `==`: 두 변수가 같은 객체, 즉 메모리의 같은 위치를 가리키는지 비교한다. +- `equals()`: 두 객체의 값이 같은지 비교한다. + +따라서 값을 비교하려면 `equals()`를 사용해야 한다. 다만 `String a = "hi"; String b = "hi";`로 초기화한 문자열들은 문자열 상수 풀에 저장되어 같은 객체를 가리키므로, 이 경우에는 `a == b`로 내용이 같은지 비교할 수도 있다. + +**Q**: 너비 우선 순회에서 맨 아래 레벨에 도달하기 전 큐의 노드 수는 $2^h$인가? + +그렇다. 예를 들어 높이 $h = 2$인 포화 이진 트리의 전체 노드 수는 $n = 7$이고, 맨 아래 레벨의 노드 수는 $4 = 2^h = (n + 1) / 2$다. diff --git a/ko/docs/index.assets/animation.mp4 b/ko/docs/index.assets/animation.mp4 new file mode 100644 index 000000000..cf3838e79 Binary files /dev/null and b/ko/docs/index.assets/animation.mp4 differ diff --git a/ko/docs/index.assets/btn_download_pdf_epub_dark.svg b/ko/docs/index.assets/btn_download_pdf_epub_dark.svg new file mode 100644 index 000000000..20892d60a --- /dev/null +++ b/ko/docs/index.assets/btn_download_pdf_epub_dark.svg @@ -0,0 +1 @@ +PDF/ePub \ No newline at end of file diff --git a/ko/docs/index.assets/btn_read_online_dark.svg b/ko/docs/index.assets/btn_read_online_dark.svg new file mode 100644 index 000000000..ddf3a6b2e --- /dev/null +++ b/ko/docs/index.assets/btn_read_online_dark.svg @@ -0,0 +1 @@ +Web \ No newline at end of file diff --git a/ko/docs/index.assets/comment.mp4 b/ko/docs/index.assets/comment.mp4 new file mode 100644 index 000000000..3f9ea340c Binary files /dev/null and b/ko/docs/index.assets/comment.mp4 differ diff --git a/ko/docs/index.assets/running_code.mp4 b/ko/docs/index.assets/running_code.mp4 new file mode 100644 index 000000000..c946fbd68 Binary files /dev/null and b/ko/docs/index.assets/running_code.mp4 differ diff --git a/ko/docs/index.html b/ko/docs/index.html new file mode 100644 index 000000000..a889a9b35 --- /dev/null +++ b/ko/docs/index.html @@ -0,0 +1,324 @@ + +
+ + +
+
+
+ + + +
+ +
+

+ 애니메이션으로 설명하고 클릭 한 번으로 실행하는 자료 구조와 알고리즘 입문서 +

+ + + + + 읽기 시작하기 + + + + + + + GitHub + +
+ +
+ + + +
+
+
+ + +
+
+ +

500개의 애니메이션 그림, 14개 프로그래밍 언어의 코드, 3000개의 커뮤니티 질문과 답변으로 자료 구조와 알고리즘의 기초를 빠르게 익힐 수 있다

+
+
+ + +
+
+

추천의 말

+
+
+

“쉽게 읽을 수 있는 자료 구조와 알고리즘 입문서다. 직접 실습하고 생각하며 배우도록 안내한다. 알고리즘 입문자에게 적극 추천한다.”

+

—— 덩쥔후이, 칭화대학교 컴퓨터학과 교수

+
+
+

“내가 자료 구조와 알고리즘을 배울 때 『Hello 알고리즘』이 있었다면 공부가 열 배는 쉬웠을 것이다!”

+

—— 리무, 아마존 수석 과학자(Senior Principal Scientist)

+
+
+
+
+ + +
+
+
+
+
+
+ + + +

애니메이션 그림

+
+

명확하고 이해하기 쉬운 내용, 완만한 학습 곡선

+

"A picture is worth a thousand words."
“그림 한 장이 천 마디 말보다 낫다”

+
+
+ +
+ +
+ +
+
+
+ + + +

클릭 한 번으로 실행

+
+

열 가지가 넘는 프로그래밍 언어와 코드 실행 시각화

+

"Talk is cheap. Show me the code."
“말보다 코드를 보여 달라”

+
+
+
+ +
+
+
+
+ + + +

함께 배우기

+
+

질문과 토론을 나누며 함께 성장하는 독자들

+

"Learning by teaching."
“가르치며 배운다”

+
+
+ +
+ +
+
+ + +
+
+ +
+

저자

+ +
+ + +
+

코드 검토자

+

다음 개발자들이 함께 힘을 모아 여러 언어의 예제 코드를 완성했다. 이들의 노력과 기여에 감사드린다!

+ +
+ + +
+

기여자

+

이 책은 오픈 소스 커뮤니티의 200명이 넘는 기여자가 함께 노력한 덕분에 꾸준히 발전하고 있다. 시간과 노력을 들여 준 모든 분께 감사드린다!

+ + 기여자 + +
+
+
diff --git a/ko/docs/index.md b/ko/docs/index.md new file mode 100644 index 000000000..818a368dd --- /dev/null +++ b/ko/docs/index.md @@ -0,0 +1,10 @@ +--- +comments: false +search: + exclude: true +hide: + - footer + - toc + - edit + - navigation +--- diff --git a/overrides/404.html b/overrides/404.html new file mode 100644 index 000000000..31e77764e --- /dev/null +++ b/overrides/404.html @@ -0,0 +1,8 @@ +{% extends "main.html" %} +{% block content %} + {% if config.theme.language == 'ko' %} +

404 - 페이지를 찾을 수 없습니다

+ {% else %} +

404 - Not found

+ {% endif %} +{% endblock %} diff --git a/overrides/javascripts/animation_player.js b/overrides/javascripts/animation_player.js index 9bb22fbfb..c85db7959 100644 --- a/overrides/javascripts/animation_player.js +++ b/overrides/javascripts/animation_player.js @@ -2,8 +2,11 @@ const ANIMATION_LABEL_PATTERN = /^<\d+>$/; const AUTO_SLIDE_INITIAL_DELAY_MS = 1000; const AUTO_SLIDE_INTERVAL_MS = 1500; - const PLAY_LABEL = "播放幻灯片"; - const PAUSE_LABEL = "暂停幻灯片"; + const IS_KOREAN = document.documentElement.lang.toLowerCase().startsWith("ko"); + const PLAY_LABEL = IS_KOREAN ? "슬라이드 재생" : "播放幻灯片"; + const PAUSE_LABEL = IS_KOREAN ? "슬라이드 일시 정지" : "暂停幻灯片"; + const PREVIOUS_LABEL = IS_KOREAN ? "이전 슬라이드" : "上一页"; + const NEXT_LABEL = IS_KOREAN ? "다음 슬라이드" : "下一页"; const SVG_NS = "http://www.w3.org/2000/svg"; const FA_PLAY_PATH = "M91.2 36.9c-12.4-6.8-27.4-6.5-39.6.7S32 57.9 32 72v368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6.7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1z"; @@ -121,7 +124,7 @@ const prevButton = createIconButton({ className: "animation-controls__prev", - ariaLabel: "上一页", + ariaLabel: PREVIOUS_LABEL, path: FA_ARROW_LEFT_PATH, }); const pageIndicator = document.createElement("span"); @@ -130,7 +133,7 @@ const nextButton = createIconButton({ className: "animation-controls__next", - ariaLabel: "下一页", + ariaLabel: NEXT_LABEL, path: FA_ARROW_RIGHT_PATH, }); diff --git a/overrides/main.html b/overrides/main.html index 0a4550c8a..a2f575419 100644 --- a/overrides/main.html +++ b/overrides/main.html @@ -1,6 +1,7 @@ {% extends "base.html" %} -{% block announce %} +{% block announce -%} +{% if config.theme.language != 'ko' %} {% if config.theme.language == 'zh' %} {% set announcements = '纸质书已发行,详情请见这里' %} {% elif config.theme.language == 'zh-Hant' %} @@ -20,4 +21,5 @@ {{ announcements }} -{% endblock %} +{% endif %} +{%- endblock %} diff --git a/overrides/partials/comments.html b/overrides/partials/comments.html index 24f227b7d..ecf62c882 100644 --- a/overrides/partials/comments.html +++ b/overrides/partials/comments.html @@ -14,6 +14,9 @@ {% elif config.theme.language == 'ru' %} {% set comm = "Оставляйте свои идеи, вопросы и предложения в комментариях" %} {% set lang = "ru" %} + {% elif config.theme.language == 'ko' %} + {% set comm = "댓글로 의견이나 질문, 제안을 남겨 주세요" %} + {% set lang = "ko" %} {% endif %} @@ -52,6 +55,23 @@ /* Set palette on initial load */ giscus.setAttribute("data-theme", getGiscusTheme()) + {% if config.theme.language == 'ko' %} + /* The Giscus client creates the iframe asynchronously with an English title. */ + ;(function() { + function labelCommentsFrame() { + var frame = document.querySelector("iframe.giscus-frame") + if (!frame) return false + frame.setAttribute("title", "댓글") + return true + } + if (labelCommentsFrame()) return + var observer = new MutationObserver(function() { + if (labelCommentsFrame()) observer.disconnect() + }) + observer.observe(giscus.parentElement, { childList: true, subtree: true }) + })() + {% endif %} + /* Register event handlers after documented loaded */ document.addEventListener("DOMContentLoaded", function() { var ref = document.querySelector("[data-md-component=palette]") diff --git a/overrides/stylesheets/extra.css b/overrides/stylesheets/extra.css index de2affd93..57b8bcf52 100644 --- a/overrides/stylesheets/extra.css +++ b/overrides/stylesheets/extra.css @@ -485,6 +485,11 @@ html:has(body[data-md-color-scheme="default"]) { font-size: min(1.45vh, 2vw); } +.hero-div--ko { + width: min(112vh, 92vw); + height: min(84vh, 69vw); +} + .hero-bg { width: 100%; height: 100%; diff --git a/ru/docs/chapter_array_and_linkedlist/array.md b/ru/docs/chapter_array_and_linkedlist/array.md index a188bf03d..31d2683a5 100644 --- a/ru/docs/chapter_array_and_linkedlist/array.md +++ b/ru/docs/chapter_array_and_linkedlist/array.md @@ -134,7 +134,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
### 2.   Доступ к элементам @@ -320,7 +320,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 3.   Вставка элемента @@ -516,7 +516,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 4.   Удаление элемента @@ -686,7 +686,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
В целом операции вставки и удаления в массиве имеют следующие недостатки. @@ -933,7 +933,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 6.   Поиск элемента @@ -1115,7 +1115,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 7.   Расширение массива @@ -1345,7 +1345,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 4.1.2   Преимущества и ограничения массива diff --git a/ru/docs/chapter_array_and_linkedlist/linked_list.md b/ru/docs/chapter_array_and_linkedlist/linked_list.md index cf9c991c1..0eda3897d 100644 --- a/ru/docs/chapter_array_and_linkedlist/linked_list.md +++ b/ru/docs/chapter_array_and_linkedlist/linked_list.md @@ -421,7 +421,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
Массив в целом - это одна переменная: например, массив `nums` содержит элементы `nums[0]` , `nums[1]` и т.д. Связный список же состоит из множества независимых объектов-узлов. **Обычно в качестве обозначения всего связного списка используют головной узел**. Например, в приведенном выше коде связный список можно обозначить как `n0` . @@ -582,7 +582,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 3.   Удаление узла @@ -785,7 +785,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 4.   Доступ к узлу @@ -989,7 +989,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 5.   Поиск узла @@ -1215,7 +1215,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 4.2.2   Сравнение массива и связного списка diff --git a/ru/docs/chapter_array_and_linkedlist/list.md b/ru/docs/chapter_array_and_linkedlist/list.md index 03aae5839..4e9483bf3 100644 --- a/ru/docs/chapter_array_and_linkedlist/list.md +++ b/ru/docs/chapter_array_and_linkedlist/list.md @@ -153,7 +153,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
### 2.   Доступ к элементам @@ -285,7 +285,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
### 3.   Вставка и удаление элементов @@ -540,7 +540,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
### 4.   Обход списка @@ -742,7 +742,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
### 5.   Конкатенация списков @@ -854,7 +854,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
### 6.   Сортировка списка @@ -953,7 +953,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
## 4.3.2   Реализация списка @@ -2297,5 +2297,5 @@ comments: true ??? pythontutor "Визуализация кода" -
+
diff --git a/ru/docs/chapter_backtracking/backtracking_algorithm.md b/ru/docs/chapter_backtracking/backtracking_algorithm.md index a905aa968..03099f969 100644 --- a/ru/docs/chapter_backtracking/backtracking_algorithm.md +++ b/ru/docs/chapter_backtracking/backtracking_algorithm.md @@ -234,7 +234,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
![Поиск узлов при прямом обходе](backtracking_algorithm.assets/preorder_find_nodes.png){ class="animation-figure" } @@ -545,7 +545,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
В каждой «попытке» мы добавляем текущий узел в `path` , чтобы записать путь. А перед «откатом» нам нужно удалить этот узел из `path` , **чтобы восстановить состояние, существовавшее до текущей попытки**. @@ -899,7 +899,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Термин «обрезка» очень нагляден. Как показано на рисунке 13-3, во время поиска **мы отсекаем ветви, не удовлетворяющие ограничениям** , тем самым избегая множества бессмысленных попыток и повышая эффективность поиска. @@ -1926,7 +1926,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Согласно условию задачи, после нахождения узла со значением $7$ мы должны продолжать поиск, **поэтому оператор `return` после записи решения нужно удалить**. На рисунке 13-4 сравниваются процессы поиска в случаях, когда `return` сохраняется и когда он удаляется. diff --git a/ru/docs/chapter_backtracking/n_queens_problem.md b/ru/docs/chapter_backtracking/n_queens_problem.md index 8155e7b4a..c51af3d70 100644 --- a/ru/docs/chapter_backtracking/n_queens_problem.md +++ b/ru/docs/chapter_backtracking/n_queens_problem.md @@ -746,7 +746,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Если размещать ферзей построчно $n$ раз, учитывая ограничение по столбцам, то начиная с первой строки и заканчивая последней мы получаем соответственно $n$, $n-1$, $\dots$, $2$, $1$ вариантов выбора, что дает $O(n!)$ времени. При записи решения нужно скопировать матрицу `state` и добавить ее в `res` , а копирование требует $O(n^2)$ времени. Следовательно, **общая временная сложность равна $O(n! \cdot n^2)$** . На практике обрезка по диагональным ограничениям дополнительно сильно уменьшает пространство поиска, поэтому фактическая эффективность часто лучше этой оценки. diff --git a/ru/docs/chapter_backtracking/permutations_problem.md b/ru/docs/chapter_backtracking/permutations_problem.md index 3372adb67..d9bfc77be 100644 --- a/ru/docs/chapter_backtracking/permutations_problem.md +++ b/ru/docs/chapter_backtracking/permutations_problem.md @@ -540,7 +540,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 13.2.2   Учет равных элементов @@ -1087,7 +1087,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Если предположить, что все элементы попарно различны, то из $n$ элементов можно получить $n!$ перестановок. При записи результата требуется копировать список длины $n$ , что занимает $O(n)$ времени. **Следовательно, временная сложность равна $O(n!n)$** . diff --git a/ru/docs/chapter_backtracking/subset_sum_problem.md b/ru/docs/chapter_backtracking/subset_sum_problem.md index 6c6e935f8..8945d7fc3 100644 --- a/ru/docs/chapter_backtracking/subset_sum_problem.md +++ b/ru/docs/chapter_backtracking/subset_sum_problem.md @@ -524,7 +524,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Если подать на этот код массив $[3, 4, 5]$ и целевое значение $9$ , то на выходе мы получим $[3, 3, 3], [4, 5], [5, 4]$ . **Хотя все подмножества с суммой $9$ успешно найдены, среди них все же присутствуют дубликаты: $[4, 5]$ и $[5, 4]$** . @@ -1085,7 +1085,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
На рисунке 13-12 показан полный процесс поиска с возвратом для массива $[3, 4, 5]$ и целевого значения $9$ . @@ -1696,7 +1696,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
На рисунке 13-14 показан процесс поиска с возвратом для массива $[4, 4, 5]$ и целевого значения $9$ . В нем используются четыре вида обрезки. Попробуйте сопоставить рисунок с комментариями в коде, чтобы понять полный процесс поиска и то, как работает каждый тип обрезки. diff --git a/ru/docs/chapter_computational_complexity/iteration_and_recursion.md b/ru/docs/chapter_computational_complexity/iteration_and_recursion.md index 818d39b07..cd4d655e8 100644 --- a/ru/docs/chapter_computational_complexity/iteration_and_recursion.md +++ b/ru/docs/chapter_computational_complexity/iteration_and_recursion.md @@ -200,7 +200,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
На рисунке 2-1 представлена блок-схема этой функции суммирования. @@ -430,7 +430,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
**Цикл `while` обладает большей степенью свободы по сравнению с циклом `for` **. В цикле `while` можно свободно управлять инициализацией и обновлением условной переменной. @@ -689,7 +689,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
В целом **код с использованием цикла `for` более компактный, а цикл `while` более гибкий**. Но они оба могут реализовать итерационную структуру. Выбор между ними определяется требованиями конкретной задачи. @@ -924,7 +924,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
На рисунке 2-2 приведена блок-схема такого вложенного цикла. @@ -1147,7 +1147,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
На рисунке 2-3 представлен рекурсивный процесс этой функции. @@ -1360,7 +1360,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Процесс выполнения хвостовой рекурсии показан на рисунке 2-5. Сравнивая обычную и хвостовую рекурсии, можно заметить, что точка выполнения операции суммирования у них различается. @@ -1586,7 +1586,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Проанализировав приведенный код, можно заметить, что внутри функции осуществляется рекурсивный вызов двух функций, **то есть из одного вызова образуются два ветвления**. Как показано на рисунке 2-6, при последующем выполнении рекурсивных вызовов в итоге образуется дерево рекурсии (recursion tree) глубиной $n$ . @@ -1931,7 +1931,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Наблюдая за приведенным выше кодом, можно заметить, что после преобразования рекурсии в итерацию код становится более сложным. Хотя во многих случаях итерация и рекурсия действительно могут быть преобразованы друг в друга, это не всегда стоит делать по двум причинам. diff --git a/ru/docs/chapter_computational_complexity/space_complexity.md b/ru/docs/chapter_computational_complexity/space_complexity.md index 63fc4a6c6..b1f4bbb2f 100644 --- a/ru/docs/chapter_computational_complexity/space_complexity.md +++ b/ru/docs/chapter_computational_complexity/space_complexity.md @@ -1179,7 +1179,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
### 2.   Линейная сложность $O(n)$ {data-toc-label="2.   Линейная сложность"} @@ -1457,7 +1457,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 2-17, глубина рекурсии этой функции равна $n$ , то есть одновременно существует $n$ еще не завершившихся функций `linear_recur()` , которые используют $O(n)$ пространства кадров стека: @@ -1629,7 +1629,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
![Линейная пространственная сложность, порождаемая рекурсивной функцией](space_complexity.assets/space_complexity_recursive_linear.png){ class="animation-figure" } @@ -1862,7 +1862,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 2-18, глубина рекурсии этой функции равна $n$ , и в каждой рекурсивной функции инициализируется массив длины $n$ , $n-1$ , $\dots$ , $2$ , $1$. Его средняя длина равна $n / 2$ , поэтому в сумме используется $O(n^2)$ пространства: @@ -2049,7 +2049,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
![Квадратичная пространственная сложность, порождаемая рекурсивной функцией](space_complexity.assets/space_complexity_recursive_quadratic.png){ class="animation-figure" } @@ -2258,7 +2258,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
![Экспоненциальная пространственная сложность, порождаемая полным бинарным деревом](space_complexity.assets/space_complexity_exponential.png){ class="animation-figure" } diff --git a/ru/docs/chapter_computational_complexity/time_complexity.md b/ru/docs/chapter_computational_complexity/time_complexity.md index 61b9527a1..1fe256f9a 100644 --- a/ru/docs/chapter_computational_complexity/time_complexity.md +++ b/ru/docs/chapter_computational_complexity/time_complexity.md @@ -1194,7 +1194,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
### 2.   Линейная сложность $O(n)$ {data-toc-label="2.   Линейная сложность"} @@ -1360,7 +1360,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Операции обхода массива и обхода связного списка имеют временную сложность $O(n)$ , где $n$ - длина массива или списка: @@ -1556,7 +1556,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Стоит отметить, что **размер входных данных $n$ нужно определять конкретно в зависимости от типа входа**. Например, в первом примере переменная $n$ сама является размером входных данных. Во втором примере размером данных служит длина массива. @@ -1774,7 +1774,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
На рисунке 2-10 сравниваются три временные сложности: постоянная, линейная и квадратичная. @@ -2097,7 +2097,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
### 4.   Экспоненциальная сложность $O(2^n)$ {data-toc-label="4.   Экспоненциальная сложность"} @@ -2381,7 +2381,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
![Экспоненциальная временная сложность](time_complexity.assets/time_complexity_exponential.png){ class="animation-figure" } @@ -2582,7 +2582,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Экспоненциальный рост происходит очень быстро и часто встречается в переборных методах, грубой силе, поиске с возвратом и тому подобных подходах. Для задач большого масштаба экспоненциальная сложность неприемлема, и обычно приходится применять динамическое программирование, жадные алгоритмы и другие стратегии. @@ -2834,7 +2834,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
![Логарифмическая временная сложность](time_complexity.assets/time_complexity_logarithmic.png){ class="animation-figure" } @@ -3052,7 +3052,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Логарифмическая сложность часто встречается в алгоритмах, основанных на стратегии «разделяй и властвуй», и отражает идеи разбиения на части и упрощения сложной задачи. Она растет медленно и считается одной из самых желательных временных сложностей после константной. @@ -3266,7 +3266,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
На рисунке 2-13 показано, как возникает линейно-логарифмическая сложность. Общее число операций на каждом уровне бинарного дерева равно $n$ , а дерево имеет $\log_2 n + 1$ уровней, поэтому временная сложность равна $O(n \log n)$ . @@ -3502,7 +3502,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
![Факториальная временная сложность](time_complexity.assets/time_complexity_factorial.png){ class="animation-figure" } @@ -3896,7 +3896,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Стоит отметить, что на практике лучшая временная сложность используется редко, поскольку обычно она достигается лишь с очень малой вероятностью и может вводить в заблуждение. **Худшая временная сложность гораздо практичнее, потому что задает безопасную оценку эффективности** и позволяет уверенно использовать алгоритм. diff --git a/ru/docs/chapter_data_structure/basic_data_types.md b/ru/docs/chapter_data_structure/basic_data_types.md index 7f01cc392..fb771462f 100644 --- a/ru/docs/chapter_data_structure/basic_data_types.md +++ b/ru/docs/chapter_data_structure/basic_data_types.md @@ -180,5 +180,5 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
diff --git a/ru/docs/chapter_divide_and_conquer/binary_search_recur.md b/ru/docs/chapter_divide_and_conquer/binary_search_recur.md index 44b019d23..7f94ee759 100644 --- a/ru/docs/chapter_divide_and_conquer/binary_search_recur.md +++ b/ru/docs/chapter_divide_and_conquer/binary_search_recur.md @@ -452,5 +452,5 @@ comments: true ??? pythontutor "Визуализация кода" -
+
diff --git a/ru/docs/chapter_divide_and_conquer/build_binary_tree_problem.md b/ru/docs/chapter_divide_and_conquer/build_binary_tree_problem.md index d495b66f8..cf2d299eb 100644 --- a/ru/docs/chapter_divide_and_conquer/build_binary_tree_problem.md +++ b/ru/docs/chapter_divide_and_conquer/build_binary_tree_problem.md @@ -514,7 +514,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
На рисунке 12-8 показан рекурсивный процесс построения двоичного дерева: каждый узел создается в фазе «спуска», а каждое ребро (ссылка) формируется в фазе «подъема». diff --git a/ru/docs/chapter_divide_and_conquer/hanota_problem.md b/ru/docs/chapter_divide_and_conquer/hanota_problem.md index b4c2e472f..eb5cf797b 100644 --- a/ru/docs/chapter_divide_and_conquer/hanota_problem.md +++ b/ru/docs/chapter_divide_and_conquer/hanota_problem.md @@ -544,7 +544,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 12-15, задача о Ханойской башне формирует дерево рекурсии высоты $n$ , в котором каждый узел представляет подзадачу и соответствует одному открытому вызову `dfs()`. **Поэтому временная сложность равна $O(2^n)$ , а пространственная сложность равна $O(n)$** . diff --git a/ru/docs/chapter_dynamic_programming/dp_problem_features.md b/ru/docs/chapter_dynamic_programming/dp_problem_features.md index 5dde0233e..0706e1747 100644 --- a/ru/docs/chapter_dynamic_programming/dp_problem_features.md +++ b/ru/docs/chapter_dynamic_programming/dp_problem_features.md @@ -320,7 +320,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
На рисунке 14-7 показан процесс динамического программирования для этой задачи. @@ -583,7 +583,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
## 14.2.2   Отсутствие последствий @@ -944,7 +944,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
В этом примере достаточно дополнительно учитывать только одно предыдущее состояние, поэтому после расширения определения состояния задача снова начинает удовлетворять свойству отсутствия последствий. Однако в некоторых задачах «зависимость от прошлого» бывает гораздо серьезнее. diff --git a/ru/docs/chapter_dynamic_programming/dp_solution_pipeline.md b/ru/docs/chapter_dynamic_programming/dp_solution_pipeline.md index 84fc8bd8d..fca1bc58f 100644 --- a/ru/docs/chapter_dynamic_programming/dp_solution_pipeline.md +++ b/ru/docs/chapter_dynamic_programming/dp_solution_pipeline.md @@ -385,7 +385,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
На рисунке 14-14 показано дерево рекурсии с корнем в $dp[2, 1]$. В нем содержатся перекрывающиеся подзадачи, и их число будет резко расти вместе с размером сетки `grid` . @@ -744,7 +744,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 14-15, после добавления мемоизации решение каждой подзадачи вычисляется только один раз, поэтому временная сложность определяется общим числом состояний, то есть равна $O(nm)$ . @@ -1119,7 +1119,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
На рисунке 14-16 показан процесс переходов состояний в задаче о минимальной сумме пути. Он проходит по всей сетке, **поэтому временная сложность равна $O(nm)$** . @@ -1507,5 +1507,5 @@ $$ ??? pythontutor "Визуализация кода" -
+
diff --git a/ru/docs/chapter_dynamic_programming/edit_distance_problem.md b/ru/docs/chapter_dynamic_programming/edit_distance_problem.md index 9da2ea7dd..756de2cb0 100644 --- a/ru/docs/chapter_dynamic_programming/edit_distance_problem.md +++ b/ru/docs/chapter_dynamic_programming/edit_distance_problem.md @@ -479,7 +479,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 14-30, процесс переходов состояния в задаче о расстоянии редактирования очень похож на задачи о рюкзаке: и там и здесь его можно рассматривать как заполнение двумерной сетки. @@ -968,5 +968,5 @@ $$ ??? pythontutor "Визуализация кода" -
+
diff --git a/ru/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md b/ru/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md index 04c10b698..15df9cf87 100644 --- a/ru/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md +++ b/ru/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -422,7 +422,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 14.1.1   Метод 1: полный перебор @@ -697,7 +697,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
На рисунке 14-3 показано дерево рекурсии, возникающее при полном переборе. Для задачи $dp[n]$ глубина дерева рекурсии равна $n$ , а временная сложность равна $O(2^n)$ . Экспоненциальный рост взрывообразен: если подать на вход достаточно большое значение $n$ , ожидание станет очень долгим. @@ -1064,7 +1064,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 14-4, **после введения мемоизации каждая перекрывающаяся подзадача вычисляется только один раз, и временная сложность оптимизируется до $O(n)$** . Это огромный скачок в эффективности. @@ -1340,7 +1340,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
На рисунке 14-5 смоделирован процесс выполнения этого кода. @@ -1577,7 +1577,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Из кода видно, что после отказа от массива `dp` пространственная сложность уменьшается с $O(n)$ до $O(1)$ . diff --git a/ru/docs/chapter_dynamic_programming/knapsack_problem.md b/ru/docs/chapter_dynamic_programming/knapsack_problem.md index 6868c0278..de0747fc8 100644 --- a/ru/docs/chapter_dynamic_programming/knapsack_problem.md +++ b/ru/docs/chapter_dynamic_programming/knapsack_problem.md @@ -340,7 +340,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 14-18, поскольку каждый предмет создает две ветви поиска - «не брать» и «брать», временная сложность равна $O(2^n)$ . @@ -708,7 +708,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
На рисунке 14-19 показаны ветви поиска, которые были отсечены благодаря мемоизации. @@ -1059,7 +1059,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 14-20, и временная сложность, и пространственная сложность определяются размером массива `dp` , то есть равны $O(n \times cap)$ . @@ -1441,5 +1441,5 @@ $$ ??? pythontutor "Визуализация кода" -
+
diff --git a/ru/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md b/ru/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md index 6a4b6db02..0e63e36a2 100644 --- a/ru/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/ru/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -373,7 +373,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
### 3.   Оптимизация пространства @@ -747,7 +747,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
## 14.5.2   Задача о размене монет @@ -1194,7 +1194,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 14-25, процесс динамического программирования для задачи о размене монет очень похож на задачу о полном рюкзаке. @@ -1609,7 +1609,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
## 14.5.3   Задача о размене монет II @@ -2011,7 +2011,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
### 3.   Оптимизация пространства @@ -2347,5 +2347,5 @@ $$ ??? pythontutor "Визуализация кода" -
+
diff --git a/ru/docs/chapter_graph/graph_operations.md b/ru/docs/chapter_graph/graph_operations.md index 59ff13687..15e0dc7ae 100644 --- a/ru/docs/chapter_graph/graph_operations.md +++ b/ru/docs/chapter_graph/graph_operations.md @@ -1208,7 +1208,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 9.2.2   Реализация на основе списка смежности @@ -2366,7 +2366,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 9.2.3   Сравнение эффективности diff --git a/ru/docs/chapter_graph/graph_traversal.md b/ru/docs/chapter_graph/graph_traversal.md index c7ed3efb2..20e7cc3e4 100644 --- a/ru/docs/chapter_graph/graph_traversal.md +++ b/ru/docs/chapter_graph/graph_traversal.md @@ -477,7 +477,7 @@ BFS обычно реализуется с помощью очереди, код ??? pythontutor "Визуализация кода" -
+
Код сравнительно абстрактен, поэтому для лучшего понимания рекомендуется сопоставлять его с тем, что показано на рисунке 9-10. @@ -937,7 +937,7 @@ BFS обычно реализуется с помощью очереди, код ??? pythontutor "Визуализация кода" -
+
Алгоритмический процесс обхода в глубину показан на рисунке 9-12. diff --git a/ru/docs/chapter_greedy/fractional_knapsack_problem.md b/ru/docs/chapter_greedy/fractional_knapsack_problem.md index dea6ead29..d4c578148 100644 --- a/ru/docs/chapter_greedy/fractional_knapsack_problem.md +++ b/ru/docs/chapter_greedy/fractional_knapsack_problem.md @@ -533,7 +533,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Встроенный алгоритм сортировки обычно имеет временную сложность $O(n \log n)$, а пространственная сложность обычно равна $O(\log n)$ или $O(n)$, в зависимости от конкретной реализации в языке программирования. diff --git a/ru/docs/chapter_greedy/greedy_algorithm.md b/ru/docs/chapter_greedy/greedy_algorithm.md index 39221a330..5a47c483c 100644 --- a/ru/docs/chapter_greedy/greedy_algorithm.md +++ b/ru/docs/chapter_greedy/greedy_algorithm.md @@ -332,7 +332,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
У вас может невольно вырваться: «Эврика!» Жадный алгоритм решает задачу размена монет всего примерно десятью строками кода. diff --git a/ru/docs/chapter_greedy/max_capacity_problem.md b/ru/docs/chapter_greedy/max_capacity_problem.md index 1e976206f..174657d6c 100644 --- a/ru/docs/chapter_greedy/max_capacity_problem.md +++ b/ru/docs/chapter_greedy/max_capacity_problem.md @@ -423,7 +423,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
### 3.   Доказательство корректности diff --git a/ru/docs/chapter_greedy/max_product_cutting_problem.md b/ru/docs/chapter_greedy/max_product_cutting_problem.md index f615110a2..7281b75aa 100644 --- a/ru/docs/chapter_greedy/max_product_cutting_problem.md +++ b/ru/docs/chapter_greedy/max_product_cutting_problem.md @@ -388,7 +388,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
![Метод вычисления максимального произведения разбиения](max_product_cutting_problem.assets/max_product_cutting_greedy_calculation.png){ class="animation-figure" } diff --git a/ru/docs/chapter_hashing/hash_algorithm.md b/ru/docs/chapter_hashing/hash_algorithm.md index 25827000f..f6fbae916 100644 --- a/ru/docs/chapter_hashing/hash_algorithm.md +++ b/ru/docs/chapter_hashing/hash_algorithm.md @@ -644,7 +644,7 @@ index = hash(key) % capacity ??? pythontutor "Визуализация кода" -
+
Нетрудно заметить, что последний шаг каждого из этих хеш-алгоритмов - взятие по модулю большого простого числа $1000000007$ , чтобы гарантировать, что хеш-значение остается в разумных границах. Стоит задуматься: почему подчеркивается именно взятие по модулю простого числа, и какие недостатки возникают при использовании составного модуля? Это интересный вопрос. @@ -1002,7 +1002,7 @@ $$ ??? pythontutor "Визуализация выполнения" -
+
Во многих языках программирования **в качестве `key` хеш-таблицы можно использовать только неизменяемые объекты** . Если, например, использовать список (динамический массив) как `key` , то после изменения содержимого списка изменится и его хеш-значение, из-за чего мы уже не сможем найти прежнее `value` в хеш-таблице. diff --git a/ru/docs/chapter_hashing/hash_collision.md b/ru/docs/chapter_hashing/hash_collision.md index d419ef6c9..330611e24 100644 --- a/ru/docs/chapter_hashing/hash_collision.md +++ b/ru/docs/chapter_hashing/hash_collision.md @@ -1519,7 +1519,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Следует отметить, что когда связный список становится очень длинным, эффективность поиска $O(n)$ оказывается низкой. **В этом случае список можно преобразовать в AVL-дерево или красно-черное дерево** , чтобы оптимизировать временную сложность поиска до $O(\log n)$ . diff --git a/ru/docs/chapter_hashing/hash_map.md b/ru/docs/chapter_hashing/hash_map.md index f9c00260c..2db2a18f8 100644 --- a/ru/docs/chapter_hashing/hash_map.md +++ b/ru/docs/chapter_hashing/hash_map.md @@ -325,7 +325,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
Существует три распространенных способа обхода хеш-таблицы: обход пар ключ-значение, обход ключей и обход значений. Примеры кода приведены ниже: @@ -547,7 +547,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
## 6.1.2   Простая реализация хеш-таблицы @@ -1755,7 +1755,7 @@ index = hash(key) % capacity ??? pythontutor "Визуализация кода" -
+
## 6.1.3   Хеш-коллизии и расширение diff --git a/ru/docs/chapter_heap/build_heap.md b/ru/docs/chapter_heap/build_heap.md index 1254528a5..212286765 100644 --- a/ru/docs/chapter_heap/build_heap.md +++ b/ru/docs/chapter_heap/build_heap.md @@ -324,7 +324,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 8.2.3   Анализ сложности diff --git a/ru/docs/chapter_heap/heap.md b/ru/docs/chapter_heap/heap.md index bfd6612b4..ffa87050b 100644 --- a/ru/docs/chapter_heap/heap.md +++ b/ru/docs/chapter_heap/heap.md @@ -420,7 +420,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
## 8.1.2   Реализация кучи @@ -808,7 +808,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 3.   Добавление элемента в кучу @@ -1213,7 +1213,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 4.   Извлечение элемента с вершины кучи @@ -1770,7 +1770,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 8.1.3   Типичные применения кучи diff --git a/ru/docs/chapter_heap/top_k.md b/ru/docs/chapter_heap/top_k.md index 99afd9d8e..d4d542c50 100644 --- a/ru/docs/chapter_heap/top_k.md +++ b/ru/docs/chapter_heap/top_k.md @@ -463,7 +463,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Всего выполняется $n$ операций добавления и извлечения из кучи, а максимальная длина кучи равна $k$ , поэтому временная сложность равна $O(n \log k)$ . Этот метод очень эффективен: когда $k$ мало, временная сложность стремится к $O(n)$. Когда $k$ велико, она все равно не превышает $O(n \log n)$ . diff --git a/ru/docs/chapter_searching/binary_search.md b/ru/docs/chapter_searching/binary_search.md index dedb886dc..c20be352b 100644 --- a/ru/docs/chapter_searching/binary_search.md +++ b/ru/docs/chapter_searching/binary_search.md @@ -364,7 +364,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
**Временная сложность равна $O(\log n)$** : в цикле двоичного поиска интервал каждый раз сокращается вдвое, поэтому число итераций равно $\log_2 n$ . @@ -688,7 +688,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Как показано на рисунке 10-3, в этих двух вариантах представления интервала различаются инициализация, условие цикла и операция сужения интервала в алгоритме двоичного поиска. diff --git a/ru/docs/chapter_searching/binary_search_edge.md b/ru/docs/chapter_searching/binary_search_edge.md index 20c1eb495..bbee1999c 100644 --- a/ru/docs/chapter_searching/binary_search_edge.md +++ b/ru/docs/chapter_searching/binary_search_edge.md @@ -226,7 +226,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 10.3.2   Поиск правой границы @@ -481,7 +481,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 2.   Преобразование в поиск элемента diff --git a/ru/docs/chapter_searching/binary_search_insertion.md b/ru/docs/chapter_searching/binary_search_insertion.md index 550a339f6..4bd9f8b94 100644 --- a/ru/docs/chapter_searching/binary_search_insertion.md +++ b/ru/docs/chapter_searching/binary_search_insertion.md @@ -317,7 +317,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 10.2.2   Случай с повторяющимися элементами @@ -662,7 +662,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
!!! tip diff --git a/ru/docs/chapter_searching/replace_linear_by_hashing.md b/ru/docs/chapter_searching/replace_linear_by_hashing.md index b2b68aa37..b5b19873a 100644 --- a/ru/docs/chapter_searching/replace_linear_by_hashing.md +++ b/ru/docs/chapter_searching/replace_linear_by_hashing.md @@ -243,7 +243,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Временная сложность этого метода равна $O(n^2)$ , а пространственная сложность равна $O(1)$ , поэтому на больших объемах данных он очень медленный. @@ -538,7 +538,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Благодаря хеш-поиску этот метод снижает временную сложность с $O(n^2)$ до $O(n)$ , существенно повышая эффективность работы. diff --git a/ru/docs/chapter_sorting/bubble_sort.md b/ru/docs/chapter_sorting/bubble_sort.md index e1dd04d6a..8ec1c93e0 100644 --- a/ru/docs/chapter_sorting/bubble_sort.md +++ b/ru/docs/chapter_sorting/bubble_sort.md @@ -292,7 +292,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.3.2   Оптимизация эффективности @@ -612,7 +612,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.3.3   Характеристики алгоритма diff --git a/ru/docs/chapter_sorting/bucket_sort.md b/ru/docs/chapter_sorting/bucket_sort.md index e6b14a3d3..d70c37a8a 100644 --- a/ru/docs/chapter_sorting/bucket_sort.md +++ b/ru/docs/chapter_sorting/bucket_sort.md @@ -439,7 +439,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.8.2   Характеристики алгоритма diff --git a/ru/docs/chapter_sorting/counting_sort.md b/ru/docs/chapter_sorting/counting_sort.md index 8d2813bc7..473195475 100644 --- a/ru/docs/chapter_sorting/counting_sort.md +++ b/ru/docs/chapter_sorting/counting_sort.md @@ -363,7 +363,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
!!! note "Связь между сортировкой подсчетом и блочной сортировкой" @@ -879,7 +879,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
## 11.9.3   Характеристики алгоритма diff --git a/ru/docs/chapter_sorting/heap_sort.md b/ru/docs/chapter_sorting/heap_sort.md index 897b66da9..f53c5f116 100644 --- a/ru/docs/chapter_sorting/heap_sort.md +++ b/ru/docs/chapter_sorting/heap_sort.md @@ -614,7 +614,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.7.2   Характеристики алгоритма diff --git a/ru/docs/chapter_sorting/insertion_sort.md b/ru/docs/chapter_sorting/insertion_sort.md index 0f5c3e65a..82d55419c 100644 --- a/ru/docs/chapter_sorting/insertion_sort.md +++ b/ru/docs/chapter_sorting/insertion_sort.md @@ -272,7 +272,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.4.2   Характеристики алгоритма diff --git a/ru/docs/chapter_sorting/merge_sort.md b/ru/docs/chapter_sorting/merge_sort.md index 88edd3a16..7b7efcb8b 100644 --- a/ru/docs/chapter_sorting/merge_sort.md +++ b/ru/docs/chapter_sorting/merge_sort.md @@ -681,7 +681,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.6.2   Характеристики алгоритма diff --git a/ru/docs/chapter_sorting/quick_sort.md b/ru/docs/chapter_sorting/quick_sort.md index 7e22b6f36..40513bf7e 100644 --- a/ru/docs/chapter_sorting/quick_sort.md +++ b/ru/docs/chapter_sorting/quick_sort.md @@ -368,7 +368,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.5.1   Алгоритм @@ -595,7 +595,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.5.2   Характеристики алгоритма @@ -1095,7 +1095,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.5.5   Оптимизация глубины рекурсии @@ -1404,5 +1404,5 @@ comments: true ??? pythontutor "Визуализация кода" -
+
diff --git a/ru/docs/chapter_sorting/radix_sort.md b/ru/docs/chapter_sorting/radix_sort.md index e8fb554ac..f6eb3c751 100644 --- a/ru/docs/chapter_sorting/radix_sort.md +++ b/ru/docs/chapter_sorting/radix_sort.md @@ -724,7 +724,7 @@ $$ ??? pythontutor "Визуализация кода" -
+
!!! question "Почему сортировка выполняется от младшего разряда к старшему?" diff --git a/ru/docs/chapter_sorting/selection_sort.md b/ru/docs/chapter_sorting/selection_sort.md index ce420ccea..655c69fe8 100644 --- a/ru/docs/chapter_sorting/selection_sort.md +++ b/ru/docs/chapter_sorting/selection_sort.md @@ -326,7 +326,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 11.2.1   Характеристики алгоритма diff --git a/ru/docs/chapter_stack_and_queue/deque.md b/ru/docs/chapter_stack_and_queue/deque.md index 2eceba311..938dc52e9 100644 --- a/ru/docs/chapter_stack_and_queue/deque.md +++ b/ru/docs/chapter_stack_and_queue/deque.md @@ -395,7 +395,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
## 5.3.2   Реализация двусторонней очереди * diff --git a/ru/docs/chapter_stack_and_queue/queue.md b/ru/docs/chapter_stack_and_queue/queue.md index 4eed5fbf6..67d650ac3 100644 --- a/ru/docs/chapter_stack_and_queue/queue.md +++ b/ru/docs/chapter_stack_and_queue/queue.md @@ -368,7 +368,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
## 5.2.2   Реализация очереди @@ -1313,7 +1313,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 2.   Реализация на основе массива @@ -2288,7 +2288,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Даже такая реализация очереди остается ограниченной: ее длина неизменяема. Однако это несложно исправить, заменив массив на динамический массив и тем самым введя механизм расширения. Заинтересованные читатели могут попробовать реализовать это самостоятельно. diff --git a/ru/docs/chapter_stack_and_queue/stack.md b/ru/docs/chapter_stack_and_queue/stack.md index 8c6ea4342..7b3cd885f 100644 --- a/ru/docs/chapter_stack_and_queue/stack.md +++ b/ru/docs/chapter_stack_and_queue/stack.md @@ -361,7 +361,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
## 5.1.2   Реализация стека @@ -1162,7 +1162,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 2.   Реализация на основе массива @@ -1804,7 +1804,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 5.1.3   Сравнение двух реализаций diff --git a/ru/docs/chapter_tree/array_representation_of_tree.md b/ru/docs/chapter_tree/array_representation_of_tree.md index 21118243b..3a31982bc 100644 --- a/ru/docs/chapter_tree/array_representation_of_tree.md +++ b/ru/docs/chapter_tree/array_representation_of_tree.md @@ -1330,7 +1330,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
## 7.3.3   Преимущества и ограничения diff --git a/ru/docs/chapter_tree/binary_search_tree.md b/ru/docs/chapter_tree/binary_search_tree.md index 14e18f614..6690482ad 100644 --- a/ru/docs/chapter_tree/binary_search_tree.md +++ b/ru/docs/chapter_tree/binary_search_tree.md @@ -340,7 +340,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 2.   Вставка узла @@ -804,7 +804,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
Как и поиск узла, вставка узла требует $O(\log n)$ времени. @@ -1590,7 +1590,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 4.   Упорядоченность симметричного обхода diff --git a/ru/docs/chapter_tree/binary_tree.md b/ru/docs/chapter_tree/binary_tree.md index 0f4143a3c..070749298 100644 --- a/ru/docs/chapter_tree/binary_tree.md +++ b/ru/docs/chapter_tree/binary_tree.md @@ -459,7 +459,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
### 2.   Вставка и удаление узлов @@ -628,7 +628,7 @@ comments: true ??? pythontutor "Визуализация выполнения" -
+
!!! tip diff --git a/ru/docs/chapter_tree/binary_tree_traversal.md b/ru/docs/chapter_tree/binary_tree_traversal.md index 7f90bfe91..dcc3812ba 100644 --- a/ru/docs/chapter_tree/binary_tree_traversal.md +++ b/ru/docs/chapter_tree/binary_tree_traversal.md @@ -336,7 +336,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
### 2.   Анализ сложности @@ -821,7 +821,7 @@ comments: true ??? pythontutor "Визуализация кода" -
+
!!! tip diff --git a/zh-hant/docs/chapter_array_and_linkedlist/array.md b/zh-hant/docs/chapter_array_and_linkedlist/array.md index e896c8f07..2d7f942f6 100755 --- a/zh-hant/docs/chapter_array_and_linkedlist/array.md +++ b/zh-hant/docs/chapter_array_and_linkedlist/array.md @@ -134,7 +134,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 2.   訪問元素 @@ -320,7 +320,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 3.   插入元素 @@ -516,7 +516,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 4.   刪除元素 @@ -686,7 +686,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
總的來看,陣列的插入與刪除操作有以下缺點。 @@ -933,7 +933,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 6.   查詢元素 @@ -1115,7 +1115,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 7.   擴容陣列 @@ -1345,7 +1345,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 4.1.2   陣列的優點與侷限性 diff --git a/zh-hant/docs/chapter_array_and_linkedlist/linked_list.md b/zh-hant/docs/chapter_array_and_linkedlist/linked_list.md index 23497dc44..d9c6e3a97 100755 --- a/zh-hant/docs/chapter_array_and_linkedlist/linked_list.md +++ b/zh-hant/docs/chapter_array_and_linkedlist/linked_list.md @@ -421,7 +421,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
陣列整體是一個變數,比如陣列 `nums` 包含元素 `nums[0]` 和 `nums[1]` 等,而鏈結串列是由多個獨立的節點物件組成的。**我們通常將頭節點當作鏈結串列的代稱**,比如以上程式碼中的鏈結串列可記作鏈結串列 `n0` 。 @@ -582,7 +582,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 3.   刪除節點 @@ -785,7 +785,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 4.   訪問節點 @@ -989,7 +989,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 5.   查詢節點 @@ -1215,7 +1215,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 4.2.2   陣列 vs. 鏈結串列 diff --git a/zh-hant/docs/chapter_array_and_linkedlist/list.md b/zh-hant/docs/chapter_array_and_linkedlist/list.md index 81f0766b9..8cc1f2037 100755 --- a/zh-hant/docs/chapter_array_and_linkedlist/list.md +++ b/zh-hant/docs/chapter_array_and_linkedlist/list.md @@ -153,7 +153,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 2.   訪問元素 @@ -285,7 +285,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 3.   插入與刪除元素 @@ -540,7 +540,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 4.   走訪串列 @@ -742,7 +742,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 5.   拼接串列 @@ -854,7 +854,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 6.   排序串列 @@ -953,7 +953,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 4.3.2   串列實現 @@ -2297,5 +2297,5 @@ comments: true ??? pythontutor "視覺化執行" -
+
diff --git a/zh-hant/docs/chapter_backtracking/backtracking_algorithm.md b/zh-hant/docs/chapter_backtracking/backtracking_algorithm.md index 4bc6c9f53..8415cf394 100644 --- a/zh-hant/docs/chapter_backtracking/backtracking_algorithm.md +++ b/zh-hant/docs/chapter_backtracking/backtracking_algorithm.md @@ -234,7 +234,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
![在前序走訪中搜索節點](backtracking_algorithm.assets/preorder_find_nodes.png){ class="animation-figure" } @@ -545,7 +545,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
在每次“嘗試”中,我們透過將當前節點新增進 `path` 來記錄路徑;而在“回退”前,我們需要將該節點從 `path` 中彈出,**以恢復本次嘗試之前的狀態**。 @@ -899,7 +899,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
“剪枝”是一個非常形象的名詞。如圖 13-3 所示,在搜尋過程中,**我們“剪掉”了不滿足約束條件的搜尋分支**,避免許多無意義的嘗試,從而提高了搜尋效率。 @@ -1926,7 +1926,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
根據題意,我們在找到值為 $7$ 的節點後應該繼續搜尋,**因此需要將記錄解之後的 `return` 語句刪除**。圖 13-4 對比了保留或刪除 `return` 語句的搜尋過程。 diff --git a/zh-hant/docs/chapter_backtracking/n_queens_problem.md b/zh-hant/docs/chapter_backtracking/n_queens_problem.md index 17f214611..23299be64 100644 --- a/zh-hant/docs/chapter_backtracking/n_queens_problem.md +++ b/zh-hant/docs/chapter_backtracking/n_queens_problem.md @@ -746,7 +746,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
逐行放置 $n$ 次,考慮列約束,則從第一行到最後一行分別有 $n$、$n-1$、$\dots$、$2$、$1$ 個選擇,使用 $O(n!)$ 時間。當記錄解時,需要複製矩陣 `state` 並新增進 `res` ,複製操作使用 $O(n^2)$ 時間。因此,**總體時間複雜度為 $O(n! \cdot n^2)$** 。實際上,根據對角線約束的剪枝也能夠大幅縮小搜尋空間,因而搜尋效率往往優於以上時間複雜度。 diff --git a/zh-hant/docs/chapter_backtracking/permutations_problem.md b/zh-hant/docs/chapter_backtracking/permutations_problem.md index acc26050c..eec9389b7 100644 --- a/zh-hant/docs/chapter_backtracking/permutations_problem.md +++ b/zh-hant/docs/chapter_backtracking/permutations_problem.md @@ -540,7 +540,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 13.2.2   考慮相等元素的情況 @@ -1087,7 +1087,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
假設元素兩兩之間互不相同,則 $n$ 個元素共有 $n!$ 種排列(階乘);在記錄結果時,需要複製長度為 $n$ 的串列,使用 $O(n)$ 時間。**因此時間複雜度為 $O(n!n)$** 。 diff --git a/zh-hant/docs/chapter_backtracking/subset_sum_problem.md b/zh-hant/docs/chapter_backtracking/subset_sum_problem.md index e1e0a00cd..55eaf94ff 100644 --- a/zh-hant/docs/chapter_backtracking/subset_sum_problem.md +++ b/zh-hant/docs/chapter_backtracking/subset_sum_problem.md @@ -503,7 +503,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
向以上程式碼輸入陣列 $[3, 4, 5]$ 和目標元素 $9$ ,輸出結果為 $[3, 3, 3], [4, 5], [5, 4]$ 。**雖然成功找出了所有和為 $9$ 的子集,但其中存在重複的子集 $[4, 5]$ 和 $[5, 4]$** 。 @@ -1064,7 +1064,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
圖 13-12 所示為將陣列 $[3, 4, 5]$ 和目標元素 $9$ 輸入以上程式碼後的整體回溯過程。 @@ -1675,7 +1675,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
圖 13-14 展示了陣列 $[4, 4, 5]$ 和目標元素 $9$ 的回溯過程,共包含四種剪枝操作。請你將圖示與程式碼註釋相結合,理解整個搜尋過程,以及每種剪枝操作是如何工作的。 diff --git a/zh-hant/docs/chapter_computational_complexity/iteration_and_recursion.md b/zh-hant/docs/chapter_computational_complexity/iteration_and_recursion.md index bf5d4ecb2..f041a5525 100644 --- a/zh-hant/docs/chapter_computational_complexity/iteration_and_recursion.md +++ b/zh-hant/docs/chapter_computational_complexity/iteration_and_recursion.md @@ -200,7 +200,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
圖 2-1 是該求和函式的流程框圖。 @@ -430,7 +430,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
**`while` 迴圈比 `for` 迴圈的自由度更高**。在 `while` 迴圈中,我們可以自由地設計條件變數的初始化和更新步驟。 @@ -675,7 +675,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
總的來說,**`for` 迴圈的程式碼更加緊湊,`while` 迴圈更加靈活**,兩者都可以實現迭代結構。選擇使用哪一個應該根據特定問題的需求來決定。 @@ -910,7 +910,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
圖 2-2 是該巢狀迴圈的流程框圖。 @@ -1133,7 +1133,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
圖 2-3 展示了該函式的遞迴過程。 @@ -1346,7 +1346,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
尾遞迴的執行過程如圖 2-5 所示。對比普通遞迴和尾遞迴,兩者的求和操作的執行點是不同的。 @@ -1572,7 +1572,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
觀察以上程式碼,我們在函式內遞迴呼叫了兩個函式,**這意味著從一個呼叫產生了兩個呼叫分支**。如圖 2-6 所示,這樣不斷遞迴呼叫下去,最終將產生一棵層數為 $n$ 的遞迴樹(recursion tree)。 @@ -1917,7 +1917,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
觀察以上程式碼,當遞迴轉化為迭代後,程式碼變得更加複雜了。儘管迭代和遞迴在很多情況下可以互相轉化,但不一定值得這樣做,有以下兩點原因。 diff --git a/zh-hant/docs/chapter_computational_complexity/space_complexity.md b/zh-hant/docs/chapter_computational_complexity/space_complexity.md index 06db42e1a..ef9ef8f21 100755 --- a/zh-hant/docs/chapter_computational_complexity/space_complexity.md +++ b/zh-hant/docs/chapter_computational_complexity/space_complexity.md @@ -1179,7 +1179,7 @@ $$ ??? pythontutor "視覺化執行" -
+
### 2.   線性階 $O(n)$ {data-toc-label="2.   線性階"} @@ -1457,7 +1457,7 @@ $$ ??? pythontutor "視覺化執行" -
+
如圖 2-17 所示,此函式的遞迴深度為 $n$ ,即同時存在 $n$ 個未返回的 `linear_recur()` 函式,使用 $O(n)$ 大小的堆疊幀空間: @@ -1617,7 +1617,7 @@ $$ ??? pythontutor "視覺化執行" -
+
![遞迴函式產生的線性階空間複雜度](space_complexity.assets/space_complexity_recursive_linear.png){ class="animation-figure" } @@ -1850,7 +1850,7 @@ $$ ??? pythontutor "視覺化執行" -
+
如圖 2-18 所示,該函式的遞迴深度為 $n$ ,在每個遞迴函式中都初始化了一個陣列,長度分別為 $n$、$n-1$、$\dots$、$2$、$1$ ,平均長度為 $n / 2$ ,因此總體佔用 $O(n^2)$ 空間: @@ -2031,7 +2031,7 @@ $$ ??? pythontutor "視覺化執行" -
+
![遞迴函式產生的平方階空間複雜度](space_complexity.assets/space_complexity_recursive_quadratic.png){ class="animation-figure" } @@ -2225,7 +2225,7 @@ $$ ??? pythontutor "視覺化執行" -
+
![滿二元樹產生的指數階空間複雜度](space_complexity.assets/space_complexity_exponential.png){ class="animation-figure" } diff --git a/zh-hant/docs/chapter_computational_complexity/time_complexity.md b/zh-hant/docs/chapter_computational_complexity/time_complexity.md index 3737f9c78..2b5c7a577 100755 --- a/zh-hant/docs/chapter_computational_complexity/time_complexity.md +++ b/zh-hant/docs/chapter_computational_complexity/time_complexity.md @@ -1194,7 +1194,7 @@ $$ ??? pythontutor "視覺化執行" -
+
### 2.   線性階 $O(n)$ {data-toc-label="2.   線性階"} @@ -1360,7 +1360,7 @@ $$ ??? pythontutor "視覺化執行" -
+
走訪陣列和走訪鏈結串列等操作的時間複雜度均為 $O(n)$ ,其中 $n$ 為陣列或鏈結串列的長度: @@ -1549,7 +1549,7 @@ $$ ??? pythontutor "視覺化執行" -
+
值得注意的是,**輸入資料大小 $n$ 需根據輸入資料的型別來具體確定**。比如在第一個示例中,變數 $n$ 為輸入資料大小;在第二個示例中,陣列長度 $n$ 為資料大小。 @@ -1767,7 +1767,7 @@ $$ ??? pythontutor "視覺化執行" -
+
圖 2-10 對比了常數階、線性階和平方階三種時間複雜度。 @@ -2076,7 +2076,7 @@ $$ ??? pythontutor "視覺化執行" -
+
### 4.   指數階 $O(2^n)$ {data-toc-label="4.   指數階"} @@ -2325,7 +2325,7 @@ $$ ??? pythontutor "視覺化執行" -
+
![指數階的時間複雜度](time_complexity.assets/time_complexity_exponential.png){ class="animation-figure" } @@ -2477,7 +2477,7 @@ $$ ??? pythontutor "視覺化執行" -
+
指數階增長非常迅速,在窮舉法(暴力搜尋、回溯等)中比較常見。對於資料規模較大的問題,指數階是不可接受的,通常需要使用動態規劃或貪婪演算法等來解決。 @@ -2674,7 +2674,7 @@ $$ ??? pythontutor "視覺化執行" -
+
![對數階的時間複雜度](time_complexity.assets/time_complexity_logarithmic.png){ class="animation-figure" } @@ -2825,7 +2825,7 @@ $$ ??? pythontutor "視覺化執行" -
+
對數階常出現於基於分治策略的演算法中,體現了“一分為多”和“化繁為簡”的演算法思想。它增長緩慢,是僅次於常數階的理想的時間複雜度。 @@ -3039,7 +3039,7 @@ $$ ??? pythontutor "視覺化執行" -
+
圖 2-13 展示了線性對數階的生成方式。二元樹的每一層的操作總數都為 $n$ ,樹共有 $\log_2 n + 1$ 層,因此時間複雜度為 $O(n \log n)$ 。 @@ -3265,7 +3265,7 @@ $$ ??? pythontutor "視覺化執行" -
+
![階乘階的時間複雜度](time_complexity.assets/time_complexity_factorial.png){ class="animation-figure" } @@ -3659,7 +3659,7 @@ $$ ??? pythontutor "視覺化執行" -
+
值得說明的是,我們在實際中很少使用最佳時間複雜度,因為通常只有在很小機率下才能達到,可能會帶來一定的誤導性。**而最差時間複雜度更為實用,因為它給出了一個效率安全值**,讓我們可以放心地使用演算法。 diff --git a/zh-hant/docs/chapter_data_structure/basic_data_types.md b/zh-hant/docs/chapter_data_structure/basic_data_types.md index 032e2eefd..d89c75be7 100644 --- a/zh-hant/docs/chapter_data_structure/basic_data_types.md +++ b/zh-hant/docs/chapter_data_structure/basic_data_types.md @@ -180,5 +180,5 @@ comments: true ??? pythontutor "視覺化執行" -
+
diff --git a/zh-hant/docs/chapter_divide_and_conquer/binary_search_recur.md b/zh-hant/docs/chapter_divide_and_conquer/binary_search_recur.md index aa39a291d..7c1c98326 100644 --- a/zh-hant/docs/chapter_divide_and_conquer/binary_search_recur.md +++ b/zh-hant/docs/chapter_divide_and_conquer/binary_search_recur.md @@ -452,5 +452,5 @@ comments: true ??? pythontutor "視覺化執行" -
+
diff --git a/zh-hant/docs/chapter_divide_and_conquer/build_binary_tree_problem.md b/zh-hant/docs/chapter_divide_and_conquer/build_binary_tree_problem.md index d625e2ff5..82aa0d22e 100644 --- a/zh-hant/docs/chapter_divide_and_conquer/build_binary_tree_problem.md +++ b/zh-hant/docs/chapter_divide_and_conquer/build_binary_tree_problem.md @@ -514,7 +514,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
圖 12-8 展示了構建二元樹的遞迴過程,各個節點是在向下“遞”的過程中建立的,而各條邊(引用)是在向上“迴”的過程中建立的。 diff --git a/zh-hant/docs/chapter_divide_and_conquer/hanota_problem.md b/zh-hant/docs/chapter_divide_and_conquer/hanota_problem.md index 66969873b..1d650b320 100644 --- a/zh-hant/docs/chapter_divide_and_conquer/hanota_problem.md +++ b/zh-hant/docs/chapter_divide_and_conquer/hanota_problem.md @@ -544,7 +544,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
如圖 12-15 所示,河內塔問題形成一棵高度為 $n$ 的遞迴樹,每個節點代表一個子問題,對應一個開啟的 `dfs()` 函式,**因此時間複雜度為 $O(2^n)$ ,空間複雜度為 $O(n)$** 。 diff --git a/zh-hant/docs/chapter_dynamic_programming/dp_problem_features.md b/zh-hant/docs/chapter_dynamic_programming/dp_problem_features.md index 0c10e0eb6..c6b7e0594 100644 --- a/zh-hant/docs/chapter_dynamic_programming/dp_problem_features.md +++ b/zh-hant/docs/chapter_dynamic_programming/dp_problem_features.md @@ -320,7 +320,7 @@ $$ ??? pythontutor "視覺化執行" -
+
圖 14-7 展示了以上程式碼的動態規劃過程。 @@ -583,7 +583,7 @@ $$ ??? pythontutor "視覺化執行" -
+
## 14.2.2   無後效性 @@ -944,7 +944,7 @@ $$ ??? pythontutor "視覺化執行" -
+
在上面的案例中,由於僅需多考慮前面一個狀態,因此我們仍然可以透過擴展狀態定義,使得問題重新滿足無後效性。然而,某些問題具有非常嚴重的“有後效性”。 diff --git a/zh-hant/docs/chapter_dynamic_programming/dp_solution_pipeline.md b/zh-hant/docs/chapter_dynamic_programming/dp_solution_pipeline.md index cd77d7810..373ab1bfe 100644 --- a/zh-hant/docs/chapter_dynamic_programming/dp_solution_pipeline.md +++ b/zh-hant/docs/chapter_dynamic_programming/dp_solution_pipeline.md @@ -385,7 +385,7 @@ $$ ??? pythontutor "視覺化執行" -
+
圖 14-14 給出了以 $dp[2, 1]$ 為根節點的遞迴樹,其中包含一些重疊子問題,其數量會隨著網格 `grid` 的尺寸變大而急劇增多。 @@ -744,7 +744,7 @@ $$ ??? pythontutor "視覺化執行" -
+
如圖 14-15 所示,在引入記憶化後,所有子問題的解只需計算一次,因此時間複雜度取決於狀態總數,即網格尺寸 $O(nm)$ 。 @@ -1119,7 +1119,7 @@ $$ ??? pythontutor "視覺化執行" -
+
圖 14-16 展示了最小路徑和的狀態轉移過程,其走訪了整個網格,**因此時間複雜度為 $O(nm)$** 。 @@ -1507,5 +1507,5 @@ $$ ??? pythontutor "視覺化執行" -
+
diff --git a/zh-hant/docs/chapter_dynamic_programming/edit_distance_problem.md b/zh-hant/docs/chapter_dynamic_programming/edit_distance_problem.md index a692f20e4..4f3de67bc 100644 --- a/zh-hant/docs/chapter_dynamic_programming/edit_distance_problem.md +++ b/zh-hant/docs/chapter_dynamic_programming/edit_distance_problem.md @@ -479,7 +479,7 @@ $$ ??? pythontutor "視覺化執行" -
+
如圖 14-30 所示,編輯距離問題的狀態轉移過程與背包問題非常類似,都可以看作填寫一個二維網格的過程。 @@ -968,5 +968,5 @@ $$ ??? pythontutor "視覺化執行" -
+
diff --git a/zh-hant/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md b/zh-hant/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md index 91bc94b68..707163d4c 100644 --- a/zh-hant/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md +++ b/zh-hant/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -422,7 +422,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 14.1.1   方法一:暴力搜尋 @@ -697,7 +697,7 @@ $$ ??? pythontutor "視覺化執行" -
+
圖 14-3 展示了暴力搜尋形成的遞迴樹。對於問題 $dp[n]$ ,其遞迴樹的深度為 $n$ ,時間複雜度為 $O(2^n)$ 。指數階屬於爆炸式增長,如果我們輸入一個比較大的 $n$ ,則會陷入漫長的等待之中。 @@ -1064,7 +1064,7 @@ $$ ??? pythontutor "視覺化執行" -
+
觀察圖 14-4 ,**經過記憶化處理後,所有重疊子問題都只需計算一次,時間複雜度最佳化至 $O(n)$** ,這是一個巨大的飛躍。 @@ -1340,7 +1340,7 @@ $$ ??? pythontutor "視覺化執行" -
+
圖 14-5 模擬了以上程式碼的執行過程。 @@ -1577,7 +1577,7 @@ $$ ??? pythontutor "視覺化執行" -
+
觀察以上程式碼,由於省去了陣列 `dp` 佔用的空間,因此空間複雜度從 $O(n)$ 降至 $O(1)$ 。 diff --git a/zh-hant/docs/chapter_dynamic_programming/knapsack_problem.md b/zh-hant/docs/chapter_dynamic_programming/knapsack_problem.md index 4656f1085..91923a025 100644 --- a/zh-hant/docs/chapter_dynamic_programming/knapsack_problem.md +++ b/zh-hant/docs/chapter_dynamic_programming/knapsack_problem.md @@ -340,7 +340,7 @@ $$ ??? pythontutor "視覺化執行" -
+
如圖 14-18 所示,由於每個物品都會產生不選和選兩條搜尋分支,因此時間複雜度為 $O(2^n)$ 。 @@ -708,7 +708,7 @@ $$ ??? pythontutor "視覺化執行" -
+
圖 14-19 展示了在記憶化搜尋中被剪掉的搜尋分支。 @@ -1059,7 +1059,7 @@ $$ ??? pythontutor "視覺化執行" -
+
如圖 14-20 所示,時間複雜度和空間複雜度都由陣列 `dp` 大小決定,即 $O(n \times cap)$ 。 @@ -1441,5 +1441,5 @@ $$ ??? pythontutor "視覺化執行" -
+
diff --git a/zh-hant/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md b/zh-hant/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md index f7e21f2ae..161a7b3fb 100644 --- a/zh-hant/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/zh-hant/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -373,7 +373,7 @@ $$ ??? pythontutor "視覺化執行" -
+
### 3.   空間最佳化 @@ -747,7 +747,7 @@ $$ ??? pythontutor "視覺化執行" -
+
## 14.5.2   零錢兌換問題 @@ -1194,7 +1194,7 @@ $$ ??? pythontutor "視覺化執行" -
+
圖 14-25 展示了零錢兌換的動態規劃過程,和完全背包問題非常相似。 @@ -1609,7 +1609,7 @@ $$ ??? pythontutor "視覺化執行" -
+
## 14.5.3   零錢兌換問題 II @@ -2011,7 +2011,7 @@ $$ ??? pythontutor "視覺化執行" -
+
### 3.   空間最佳化 @@ -2347,5 +2347,5 @@ $$ ??? pythontutor "視覺化執行" -
+
diff --git a/zh-hant/docs/chapter_graph/graph_operations.md b/zh-hant/docs/chapter_graph/graph_operations.md index d19f9d74a..8be1f1214 100644 --- a/zh-hant/docs/chapter_graph/graph_operations.md +++ b/zh-hant/docs/chapter_graph/graph_operations.md @@ -1208,7 +1208,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 9.2.2   基於鄰接表的實現 @@ -2366,7 +2366,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 9.2.3   效率對比 diff --git a/zh-hant/docs/chapter_graph/graph_traversal.md b/zh-hant/docs/chapter_graph/graph_traversal.md index 2e2c8f3da..8424394eb 100644 --- a/zh-hant/docs/chapter_graph/graph_traversal.md +++ b/zh-hant/docs/chapter_graph/graph_traversal.md @@ -477,7 +477,7 @@ BFS 通常藉助佇列來實現,程式碼如下所示。佇列具有“先入 ??? pythontutor "視覺化執行" -
+
程式碼相對抽象,建議對照圖 9-10 來加深理解。 @@ -937,7 +937,7 @@ BFS 通常藉助佇列來實現,程式碼如下所示。佇列具有“先入 ??? pythontutor "視覺化執行" -
+
深度優先走訪的演算法流程如圖 9-12 所示。 diff --git a/zh-hant/docs/chapter_greedy/fractional_knapsack_problem.md b/zh-hant/docs/chapter_greedy/fractional_knapsack_problem.md index da5fed245..781d26afb 100644 --- a/zh-hant/docs/chapter_greedy/fractional_knapsack_problem.md +++ b/zh-hant/docs/chapter_greedy/fractional_knapsack_problem.md @@ -533,7 +533,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
內建排序演算法的時間複雜度通常為 $O(n \log n)$ ,空間複雜度通常為 $O(\log n)$ 或 $O(n)$ ,取決於程式語言的具體實現。 diff --git a/zh-hant/docs/chapter_greedy/greedy_algorithm.md b/zh-hant/docs/chapter_greedy/greedy_algorithm.md index 34e002c9b..b3ee85b96 100644 --- a/zh-hant/docs/chapter_greedy/greedy_algorithm.md +++ b/zh-hant/docs/chapter_greedy/greedy_algorithm.md @@ -332,7 +332,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
你可能會不由地發出感嘆:So clean !貪婪演算法僅用約十行程式碼就解決了零錢兌換問題。 diff --git a/zh-hant/docs/chapter_greedy/max_capacity_problem.md b/zh-hant/docs/chapter_greedy/max_capacity_problem.md index 0981b8f9f..e2cedea06 100644 --- a/zh-hant/docs/chapter_greedy/max_capacity_problem.md +++ b/zh-hant/docs/chapter_greedy/max_capacity_problem.md @@ -423,7 +423,7 @@ $$ ??? pythontutor "視覺化執行" -
+
### 3.   正確性證明 diff --git a/zh-hant/docs/chapter_greedy/max_product_cutting_problem.md b/zh-hant/docs/chapter_greedy/max_product_cutting_problem.md index 2f9627fd3..58d0cca35 100644 --- a/zh-hant/docs/chapter_greedy/max_product_cutting_problem.md +++ b/zh-hant/docs/chapter_greedy/max_product_cutting_problem.md @@ -388,7 +388,7 @@ $$ ??? pythontutor "視覺化執行" -
+
![最大切分乘積的計算方法](max_product_cutting_problem.assets/max_product_cutting_greedy_calculation.png){ class="animation-figure" } diff --git a/zh-hant/docs/chapter_hashing/hash_algorithm.md b/zh-hant/docs/chapter_hashing/hash_algorithm.md index 30bb65224..0ca553260 100644 --- a/zh-hant/docs/chapter_hashing/hash_algorithm.md +++ b/zh-hant/docs/chapter_hashing/hash_algorithm.md @@ -644,7 +644,7 @@ index = hash(key) % capacity ??? pythontutor "視覺化執行" -
+
觀察發現,每種雜湊演算法的最後一步都是對大質數 $1000000007$ 取模,以確保雜湊值在合適的範圍內。值得思考的是,為什麼要強調對質數取模,或者說對合數取模的弊端是什麼?這是一個有趣的問題。 @@ -1002,7 +1002,7 @@ $$ ??? pythontutor "視覺化執行" -
+
在許多程式語言中,**只有不可變物件才可作為雜湊表的 `key`** 。假如我們將串列(動態陣列)作為 `key` ,當串列的內容發生變化時,它的雜湊值也隨之改變,我們就無法在雜湊表中查詢到原先的 `value` 了。 diff --git a/zh-hant/docs/chapter_hashing/hash_collision.md b/zh-hant/docs/chapter_hashing/hash_collision.md index 26234b901..8d69b5068 100644 --- a/zh-hant/docs/chapter_hashing/hash_collision.md +++ b/zh-hant/docs/chapter_hashing/hash_collision.md @@ -1519,7 +1519,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
值得注意的是,當鏈結串列很長時,查詢效率 $O(n)$ 很差。**此時可以將鏈結串列轉換為“AVL 樹”或“紅黑樹”**,從而將查詢操作的時間複雜度最佳化至 $O(\log n)$ 。 diff --git a/zh-hant/docs/chapter_hashing/hash_map.md b/zh-hant/docs/chapter_hashing/hash_map.md index f95f82e2a..328e159b2 100755 --- a/zh-hant/docs/chapter_hashing/hash_map.md +++ b/zh-hant/docs/chapter_hashing/hash_map.md @@ -325,7 +325,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
雜湊表有三種常用的走訪方式:走訪鍵值對、走訪鍵和走訪值。示例程式碼如下: @@ -547,7 +547,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 6.1.2   雜湊表簡單實現 @@ -1755,7 +1755,7 @@ index = hash(key) % capacity ??? pythontutor "視覺化執行" -
+
## 6.1.3   雜湊衝突與擴容 diff --git a/zh-hant/docs/chapter_heap/build_heap.md b/zh-hant/docs/chapter_heap/build_heap.md index 4d2c51aa2..21cba5471 100644 --- a/zh-hant/docs/chapter_heap/build_heap.md +++ b/zh-hant/docs/chapter_heap/build_heap.md @@ -324,7 +324,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 8.2.3   複雜度分析 diff --git a/zh-hant/docs/chapter_heap/heap.md b/zh-hant/docs/chapter_heap/heap.md index 857e2e994..2dd829c0d 100644 --- a/zh-hant/docs/chapter_heap/heap.md +++ b/zh-hant/docs/chapter_heap/heap.md @@ -420,7 +420,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 8.1.2   堆積的實現 @@ -808,7 +808,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 3.   元素入堆積 @@ -1213,7 +1213,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 4.   堆積頂元素出堆積 @@ -1770,7 +1770,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 8.1.3   堆積的常見應用 diff --git a/zh-hant/docs/chapter_heap/top_k.md b/zh-hant/docs/chapter_heap/top_k.md index 6b86e1c38..817fab6b0 100644 --- a/zh-hant/docs/chapter_heap/top_k.md +++ b/zh-hant/docs/chapter_heap/top_k.md @@ -463,7 +463,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
總共執行了 $n$ 輪入堆積和出堆積,堆積的最大長度為 $k$ ,因此時間複雜度為 $O(n \log k)$ 。該方法的效率很高,當 $k$ 較小時,時間複雜度趨向 $O(n)$ ;當 $k$ 較大時,時間複雜度不會超過 $O(n \log n)$ 。 diff --git a/zh-hant/docs/chapter_searching/binary_search.md b/zh-hant/docs/chapter_searching/binary_search.md index f20fe0d38..11b50a500 100755 --- a/zh-hant/docs/chapter_searching/binary_search.md +++ b/zh-hant/docs/chapter_searching/binary_search.md @@ -364,7 +364,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
**時間複雜度為 $O(\log n)$** :在二分迴圈中,區間每輪縮小一半,因此迴圈次數為 $\log_2 n$ 。 @@ -688,7 +688,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
如圖 10-3 所示,在兩種區間表示下,二分搜尋演算法的初始化、迴圈條件和縮小區間操作皆有所不同。 diff --git a/zh-hant/docs/chapter_searching/binary_search_edge.md b/zh-hant/docs/chapter_searching/binary_search_edge.md index 13cea3fac..94f914684 100644 --- a/zh-hant/docs/chapter_searching/binary_search_edge.md +++ b/zh-hant/docs/chapter_searching/binary_search_edge.md @@ -226,7 +226,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 10.3.2   查詢右邊界 @@ -481,7 +481,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 2.   轉化為查詢元素 diff --git a/zh-hant/docs/chapter_searching/binary_search_insertion.md b/zh-hant/docs/chapter_searching/binary_search_insertion.md index 8b49db686..199d03210 100644 --- a/zh-hant/docs/chapter_searching/binary_search_insertion.md +++ b/zh-hant/docs/chapter_searching/binary_search_insertion.md @@ -317,7 +317,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 10.2.2   存在重複元素的情況 @@ -662,7 +662,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
!!! tip diff --git a/zh-hant/docs/chapter_searching/replace_linear_by_hashing.md b/zh-hant/docs/chapter_searching/replace_linear_by_hashing.md index ed4b8cd53..61438ae84 100755 --- a/zh-hant/docs/chapter_searching/replace_linear_by_hashing.md +++ b/zh-hant/docs/chapter_searching/replace_linear_by_hashing.md @@ -243,7 +243,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
此方法的時間複雜度為 $O(n^2)$ ,空間複雜度為 $O(1)$ ,在大資料量下非常耗時。 @@ -538,7 +538,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
此方法透過雜湊查詢將時間複雜度從 $O(n^2)$ 降至 $O(n)$ ,大幅提升執行效率。 diff --git a/zh-hant/docs/chapter_sorting/bubble_sort.md b/zh-hant/docs/chapter_sorting/bubble_sort.md index 71a388a93..980c1e3fb 100755 --- a/zh-hant/docs/chapter_sorting/bubble_sort.md +++ b/zh-hant/docs/chapter_sorting/bubble_sort.md @@ -292,7 +292,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.3.2   效率最佳化 @@ -597,7 +597,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.3.3   演算法特性 diff --git a/zh-hant/docs/chapter_sorting/bucket_sort.md b/zh-hant/docs/chapter_sorting/bucket_sort.md index 08732e95e..0ad17a82a 100644 --- a/zh-hant/docs/chapter_sorting/bucket_sort.md +++ b/zh-hant/docs/chapter_sorting/bucket_sort.md @@ -439,7 +439,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.8.2   演算法特性 diff --git a/zh-hant/docs/chapter_sorting/counting_sort.md b/zh-hant/docs/chapter_sorting/counting_sort.md index 633acb948..d56e8e799 100644 --- a/zh-hant/docs/chapter_sorting/counting_sort.md +++ b/zh-hant/docs/chapter_sorting/counting_sort.md @@ -363,7 +363,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
!!! note "計數排序與桶排序的關聯" @@ -879,7 +879,7 @@ $$ ??? pythontutor "視覺化執行" -
+
## 11.9.3   演算法特性 diff --git a/zh-hant/docs/chapter_sorting/heap_sort.md b/zh-hant/docs/chapter_sorting/heap_sort.md index 2e5ddc141..6035b8626 100644 --- a/zh-hant/docs/chapter_sorting/heap_sort.md +++ b/zh-hant/docs/chapter_sorting/heap_sort.md @@ -614,7 +614,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.7.2   演算法特性 diff --git a/zh-hant/docs/chapter_sorting/insertion_sort.md b/zh-hant/docs/chapter_sorting/insertion_sort.md index b83d2cbad..de0bffd99 100755 --- a/zh-hant/docs/chapter_sorting/insertion_sort.md +++ b/zh-hant/docs/chapter_sorting/insertion_sort.md @@ -272,7 +272,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.4.2   演算法特性 diff --git a/zh-hant/docs/chapter_sorting/merge_sort.md b/zh-hant/docs/chapter_sorting/merge_sort.md index aafa41b1c..8f70f6670 100755 --- a/zh-hant/docs/chapter_sorting/merge_sort.md +++ b/zh-hant/docs/chapter_sorting/merge_sort.md @@ -681,7 +681,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.6.2   演算法特性 diff --git a/zh-hant/docs/chapter_sorting/quick_sort.md b/zh-hant/docs/chapter_sorting/quick_sort.md index d13ba050c..eca7f4ef4 100755 --- a/zh-hant/docs/chapter_sorting/quick_sort.md +++ b/zh-hant/docs/chapter_sorting/quick_sort.md @@ -368,7 +368,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.5.1   演算法流程 @@ -595,7 +595,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.5.2   演算法特性 @@ -1084,7 +1084,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.5.5   遞迴深度最佳化 @@ -1373,5 +1373,5 @@ comments: true ??? pythontutor "視覺化執行" -
+
diff --git a/zh-hant/docs/chapter_sorting/radix_sort.md b/zh-hant/docs/chapter_sorting/radix_sort.md index a5ac0bbe0..0e9902750 100644 --- a/zh-hant/docs/chapter_sorting/radix_sort.md +++ b/zh-hant/docs/chapter_sorting/radix_sort.md @@ -718,7 +718,7 @@ $$ ??? pythontutor "視覺化執行" -
+
!!! question "為什麼從最低位開始排序?" diff --git a/zh-hant/docs/chapter_sorting/selection_sort.md b/zh-hant/docs/chapter_sorting/selection_sort.md index 0fd336f96..22892c5b5 100644 --- a/zh-hant/docs/chapter_sorting/selection_sort.md +++ b/zh-hant/docs/chapter_sorting/selection_sort.md @@ -326,7 +326,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 11.2.1   演算法特性 diff --git a/zh-hant/docs/chapter_stack_and_queue/deque.md b/zh-hant/docs/chapter_stack_and_queue/deque.md index ad47bf4d2..b2f626c6e 100644 --- a/zh-hant/docs/chapter_stack_and_queue/deque.md +++ b/zh-hant/docs/chapter_stack_and_queue/deque.md @@ -395,7 +395,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 5.3.2   雙向佇列實現 * diff --git a/zh-hant/docs/chapter_stack_and_queue/queue.md b/zh-hant/docs/chapter_stack_and_queue/queue.md index a85f1fe6b..e96a5802a 100755 --- a/zh-hant/docs/chapter_stack_and_queue/queue.md +++ b/zh-hant/docs/chapter_stack_and_queue/queue.md @@ -368,7 +368,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 5.2.2   佇列實現 @@ -1313,7 +1313,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 2.   基於陣列的實現 @@ -2288,7 +2288,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
以上實現的佇列仍然具有侷限性:其長度不可變。然而,這個問題不難解決,我們可以將陣列替換為動態陣列,從而引入擴容機制。有興趣的讀者可以嘗試自行實現。 diff --git a/zh-hant/docs/chapter_stack_and_queue/stack.md b/zh-hant/docs/chapter_stack_and_queue/stack.md index 2642e5689..05d549eba 100755 --- a/zh-hant/docs/chapter_stack_and_queue/stack.md +++ b/zh-hant/docs/chapter_stack_and_queue/stack.md @@ -361,7 +361,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 5.1.2   堆疊的實現 @@ -1162,7 +1162,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 2.   基於陣列的實現 @@ -1804,7 +1804,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 5.1.3   兩種實現對比 diff --git a/zh-hant/docs/chapter_tree/array_representation_of_tree.md b/zh-hant/docs/chapter_tree/array_representation_of_tree.md index eb3e75ffa..83ed1d607 100644 --- a/zh-hant/docs/chapter_tree/array_representation_of_tree.md +++ b/zh-hant/docs/chapter_tree/array_representation_of_tree.md @@ -1330,7 +1330,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
## 7.3.3   優點與侷限性 diff --git a/zh-hant/docs/chapter_tree/binary_search_tree.md b/zh-hant/docs/chapter_tree/binary_search_tree.md index 4c2ad78df..3ab3b0af5 100755 --- a/zh-hant/docs/chapter_tree/binary_search_tree.md +++ b/zh-hant/docs/chapter_tree/binary_search_tree.md @@ -340,7 +340,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 2.   插入節點 @@ -804,7 +804,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
與查詢節點相同,插入節點使用 $O(\log n)$ 時間。 @@ -1590,7 +1590,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 4.   中序走訪有序 diff --git a/zh-hant/docs/chapter_tree/binary_tree.md b/zh-hant/docs/chapter_tree/binary_tree.md index 14aff81f2..4bb843d16 100644 --- a/zh-hant/docs/chapter_tree/binary_tree.md +++ b/zh-hant/docs/chapter_tree/binary_tree.md @@ -459,7 +459,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 2.   插入與刪除節點 @@ -628,7 +628,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
!!! tip diff --git a/zh-hant/docs/chapter_tree/binary_tree_traversal.md b/zh-hant/docs/chapter_tree/binary_tree_traversal.md index ad8d1a370..2f67359aa 100755 --- a/zh-hant/docs/chapter_tree/binary_tree_traversal.md +++ b/zh-hant/docs/chapter_tree/binary_tree_traversal.md @@ -336,7 +336,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
### 2.   複雜度分析 @@ -821,7 +821,7 @@ comments: true ??? pythontutor "視覺化執行" -
+
!!! tip