Comparação Experimental de Algoritmos de Ordenação

418 visualizações

Publicada em

Discussão sobre o tempo de execução de algoritmos de ordenação em comparação com sua análise assintótica.

Publicada em: Educação
0 comentários
1 gostou
Estatísticas
Notas
  • Seja o primeiro a comentar

Sem downloads
Visualizações
Visualizações totais
418
No SlideShare
0
A partir de incorporações
0
Número de incorporações
4
Ações
Compartilhamentos
0
Downloads
5
Comentários
0
Gostaram
1
Incorporações 0
Nenhuma incorporação

Nenhuma nota no slide

Comparação Experimental de Algoritmos de Ordenação

  1. 1. Comparação Experimental de Algoritmos de Ordenação Jéssica Valeska da Silva Lenon Fachiano Silva Prof. Dr. Danilo Medeiros Eler Análise e Projeto de Algoritmo PPG Ciência da Computação 2015
  2. 2. 1 Introdução Este trabalho foi realizado na disciplina de Análise e Projeto de Algoritmos do Programa de Pós-Graduação em Ciência da Computação da UNESP. 1.1 Objetivo O presente trabalho tem por objetivo apresentar a análise de algoritmos de or- denação, comparando resultados experimentais com a análise assintótica destes mesmos procedimentos. 1.2 Metodologia 1.2.1 Materiais Para realização da análise experimental, utilizou-se um notebook LG P420-G.BE41P1 (5100), sistema operacional Windows 8.1 Pro 64 bits, processador Intel(R) Core(TM) i3- 2310M CPU @ 2.10 GHz e 4 Gb de memória RAM. Os algoritmos foram codificados em linguagem Java, contando com apoio do NetBeans IDE 8.0.2. 1.2.2 Realização do Experimento Uma vez codificados os algoritmos, mediu-se o intervalo de tempo necessário para cada um realizar o procedimento de ordenação. Como entrada para o método, utilizou-se um vetor do tipo inteiro (𝑖𝑛𝑡[ ] 𝑣𝑒𝑐𝑡𝑜𝑟). Contudo, alguns algoritmos executavam muito rápido o procedimento, dificultando a captura do intervalo de tempo. Desta maneira, optou-se por realizar a tarefa 10 vezes para uma mesma entrada. Assim, o vetor de entrada foi copiado dez vezes, sendo cada um ordenado uma vez e seus tempos somados. Foram utilizados como entrada vetores de ordenados em ordem crescente, decres- cente e aleatórios, sendo os dois primeiros gerados por um função da classe 𝑉 𝑒𝑐𝑡𝑜𝑟𝐺𝑒𝑛𝑒𝑟𝑎𝑡𝑜𝑟 e o terceiro copiado de um arquivo, a fim de garantir que os mesmos valores fossem utili- zados em todas as medidas. O comprimento dos vetores foram: 5000, 10000, 20000, 25000 e 30000 elementos. Por fim, verificou-se o tempo de execução para todos os métodos, utilizando cada combinação de orientação de vetor e comprimento como entrada. Contudo, a fim de evitar certos erros derivados do sistema, o procedimento foi repetido dez vezes para cada entrada.
  3. 3. Como saída, foi gerado um arquivo 𝑐𝑠𝑣 com os dados brutos. Os gráficos para análise foram gerados por meio do software LibreOffice Calc 4.4. No capítulo 2 são apresentados os resultados dos experimentos e a comparação com a análise assintótica. Por fim, no capítulo 3, é apresentada um discussão sobre os resultados obtidos.
  4. 4. 2 Experimento e Análise dos Algoritmos de Ordenação 2.1 BubbleSort O algoritmo BubbleSort baseia-se em troca para realizar a ordenação de elementos. Compara dois elementos; caso o segundo seja menor que o primeiro, troca suas posições. É necessário percorrer o vetor várias vezes, sempre comparando elementos adjacentes. Em 𝐴𝑙𝑔𝑜𝑟𝑖𝑡ℎ𝑚 1 é possível observar um pseudcódigo desse algoritmo. Algorithm 1 BubbleSort 1: function BubbleSort(V, N) 2: for 𝑗 < −1 : 𝑁 − 1 do 3: for 𝑖 < −1 : 𝑁 − 1 do 4: if 𝐴[𝑖] > 𝐴[𝑖 + 1] then 5: 𝑎𝑢𝑥 < − 𝐴[𝑖] 6: 𝐴[𝑖] < − 𝐴[𝑖 + 1] 7: 𝐴[𝑖 + 1] < − 𝑎𝑢𝑥 8: end if 9: end for 10: end for 11: end function Pode-se observar que o método é dominado por dois laços encadeados da ordem de 𝑛 cada um, sendo 𝑛 o número de elementos do vetor de entrada. Desta maneira, em seu pior caso, o algoritmo apresenta complexidade da ordem 𝑂(𝑛2 ). Contudo, em seu melhor caso (vetor de entrada em ordem ascendente), a instrução interna não é executada, tornando o BubbleSort 𝑂(𝑛). Uma interessante alteração que pode ser realizada neste algoritmo é a verificar se o vetor já está ordenado. As instruções acrescida possuem complexidade 𝑂(1), não afetando a ordem de complexidade do programa e evitando que sejam realizadas iterações desnecessárias. Na Figura 1 é possível observar que a modificação no algoritmo não alterar o número de trocas de elementos no vetores. Contudo, como observado nas figuras 2 e 3, o tempo de execução para o algoritmo alterado é menor. Essa característica é verificada para qualquer tipo de vetor de entrada, mas mais notável no de ordem ascendente.
  5. 5. (a) BubbleSort original (b) BubbleSort melhorado Figura 1 – Movimento de registros Figura 2 – Tempo de execução do algoritmo BubbleSort Figura 3 – Tempo de execução para o algoritmo BubbleSort Melhorado
  6. 6. 2.2 QuickSort O algoritmo QuickSort utiliza a ideia de dividir e conquistar para trocar elementos distantes. Nessa abordagem, um vetor é dividido em dois menores que serão ordenados independentemente e combinados para produzir o resultado final. Existem basicamente três passos: eleja um elemento do vetor como pivô; reordene os elementos de tal maneira que todos os que estiverem a esquerda do pivô sejam menores que ele e o que estiverem à direita sejam maiores (após este passo o pivô está na posição correta); remaneje cada subvetor de maneira independente. Em 𝐴𝑙𝑔𝑜𝑟𝑖𝑡ℎ𝑚 2 é possível observar um pseudocódigo do QuickSort. Vale observar que a definição do elemento pivô e feita na 𝑓 𝑢𝑛𝑐𝑡𝑖𝑜𝑛 𝑃 𝑎𝑟𝑡𝑖𝑐𝑎𝑜. Algorithm 2 QuickSort 1: function QuickSort(V, p, r) 2: if 𝑝 < 𝑟 then 3: 𝑞 < −𝑃 𝑎𝑟𝑡𝑖𝑐𝑎𝑜(𝑉, 𝑝, 𝑟) 4: 𝑄𝑢𝑖𝑐𝑘𝑆𝑜𝑟𝑡(𝑉, 𝑝, 𝑞 − 1) 5: 𝑄𝑢𝑖𝑐𝑘𝑆𝑜𝑟𝑡(𝑉, 𝑞 + 1, 𝑟) 6: end if 7: end function 8: 9: function Particao(V,p,r) 10: 𝑥 < −𝑉 [𝑝] 11: 𝑢𝑝 < −𝑟 12: 𝑑𝑜𝑤𝑛 < −𝑝 13: while down < p do 14: while V[down] <= x do 15: 𝑑𝑜𝑤𝑛 < −𝑑𝑜𝑤𝑛 + 1 16: end while 17: while V[up] > x do 18: 𝑢𝑝 < −𝑢𝑝 − 1 19: end while 20: if down < p then 21: 𝑡𝑟𝑜𝑐𝑎(𝑉 [𝑑𝑜𝑤𝑛], 𝑉 [𝑢𝑝]) 22: end if 23: end while 24: 𝑉 [𝑝] < −𝑉 [𝑢𝑝] 25: 𝑉 [𝑢𝑝] < −𝑥 26: return up 27: end function Seguindo o Teorema mestre da complexidade de algoritmos, tem-se que a recursão desse algoritmo pode representada por 𝑇(𝑛) = 2𝑇(𝑛/2) + 𝑛 (2.1)
  7. 7. Desta maneira, pelo segundo caso, obtém-se: 𝑐1 * 𝑛𝑙𝑜𝑔2 2 ≤ 𝑛 ≤ 𝑐2 * 𝑛𝑙𝑜𝑔2 2 (2.2) Tomando 𝑐1 = 𝑐2 = 1, têm-se uma afirmação verdadeira. Logo, a complexidade do QuickSort é dado por Θ(𝑛 * 𝑙𝑜𝑔𝑛) (2.3) Contudo, vale ressaltar que no pior caso desse algoritmo (vetor ordenado), ele apresenta comportamento próximo de Θ(𝑛2 ) . Isso deve-se ao fato de que a cada divisão são criadas uma partição com 1 elemento e outra com 𝑛 − 1, gerando uma recorrência como descrito na Equação (2.4). 𝑇(𝑛) = 𝑇(𝑛 − 1) + 𝑛 (2.4) (a) QuickSort: primeiro elemento como pivô (b) QuickSort: elemento central como pivô Figura 4 – Movimento de registros A importância da escolha do pivô pode ser observada na Figura 4 . Em 4a, que utiliza o primeiro elemento do vetor para esta tarefa, o número de movimentos de registros é muito superior ao de 4b, que utiliza o elemento central, considerando um vetor como valores aleatórios como entrada. Contudo, no pior caso, apesar de próximos, a primeira abordagem realiza um número menor de movimentos. Porém, isso não implica diretamente em um tempo superior de execução. Obser- vando as Figuras 5 e 6, nota-se que utilizando o primeiro elemento como pivô, o algoritmo QuickSort registrou tempo de excução próximo de zero, enquanto a abordagem que faz uso do elemento central apresentou um tempo superior. Contudo, convém ressaltar que para vetores já ordenados, a segunda apresentou tempo de execução idêntico, enquanto a primeira foi superior em ambos casos.
  8. 8. Figura 5 – Tempo de execução do algoritmo QuickSort: primeiro elemento como pivô Figura 6 – Tempo de execução para o algoritmo QuickSort: elemento central como pivô. 2.3 InsertionSort InsertionSort (Inserção Simples) é um algoritmo que consiste basicamente em orde- nar um conjunto inserindo seus elementos em um subconjunto já ordenado. Em 𝐴𝑙𝑔𝑜𝑟𝑖𝑡ℎ𝑚 3, é possível um pseudocódigo referente a esta abordagem. (𝑛 − 1) + (𝑛 − 2) + (𝑛 − 3) + ... + 2 + 1 = (𝑛 − 1) * 𝑛 2 (2.5) A complexidade deste algoritmo é dominada pelos dois 𝑓 𝑜𝑟. O conjunto de instru- ções referentes a essas estruturas de repetição, cria um somatório de comparações como o visto na equação (2.5). Assim, pode-se observar que no pior caso esse algoritmo é 𝑂(𝑛2 ).
  9. 9. Algorithm 3 InsertionSort 1: function InsertionSort(V, N) 2: for 𝑘 < −1; 𝑘 < 𝑛; 𝑘 + + do 3: 𝑦 < − 𝑉 [𝑘] 4: for 𝑖 < −𝑘 − 1 : 𝑖 >= 0 𝐴𝑁 𝐷 𝑉 [𝑖] > 𝑦 do 5: 𝑉 [𝑖 + 1] < − 𝑉 [𝑖] 6: end for 7: 𝑉 [𝑖 + 1] < − 𝑦 8: end for 9: end function Contudo, para um vetor já ordenado, a complexidade vai ser reduzida a percorrer os 𝑛 elementos do vetor, sendo dominado assintoticamente por 𝑂(𝑛)(LEISERSON et al., 2002). Figura 7 – Número de movimentos de registro do algoritmo InsertionSort Figura 8 – Tempo de execução do algoritmo InsertionSort Na Figura 7, observa-se que que o número de movimentos para o vetor ascendente é nulo. Já para vetores em ordem reversa e com elementos aleatórios, esses valores são
  10. 10. relativamente elevados. Convém ainda ressaltar que isso reflete no tempo de execução, como visto na Figura 8. 2.4 ShellSort Este algoritmo pode ser considerado uma melhoria do InsertionSort, permitindo trocas entre elementos distantes entre si. Consiste basicamente em dividir a entrada em k-subconjuntos e aplicar a Inserção Simples a cada um, sendo que k é reduzido sucessiva- mente. Em 𝐴𝑙𝑔𝑜𝑟𝑖𝑡ℎ𝑚 4 é possível observar um pseudocódigo deste algoritmo. Algorithm 4 ShellSort 1: function ShellSort(V, N, increments, numinc) 2: for 𝑖 = 0; 𝑖 < 𝑛𝑢𝑚𝑖𝑛𝑐; 𝑖 + + do 3: 𝑠𝑝𝑎𝑛 < − 𝑖𝑛𝑐𝑟𝑒𝑚𝑒𝑛𝑡𝑠[𝑖] 4: for 𝑗 < −𝑠𝑝𝑎𝑛; 𝑗 < 𝑁; 𝑗 + + do 5: 𝑦 < − 𝑉 [𝑗] 6: end for 7: 𝑉 [𝑘 + 𝑠𝑝𝑎𝑛] < − 𝑦 8: end for 9: end function A complexidade deste algoritmo é um problema aberto. Na verdade, sua com- plexidade depende da sequência de 𝑔𝑎𝑝 e ninguém ainda foi capaz de analisar seu có- digo(ZIVIANI, 2007). Contudo, podem ser inferidos os teoremas 2.4.1 e 2.4.2(LANG, 2010). Figura 9 – Número de movimentos de registro do algoritmo ShellSort
  11. 11. Teorema 2.4.1 Para a sequência de incrementos 1, 3, 7, 15, 31, 63, 127, ..., 2 𝑘 − 1, o algoritmo ShellSort necessita de 𝑂(𝑛 * √ 𝑛) passos para ordenar um vetor que possui 𝑛 elementos. Teorema 2.4.2 Para a sequência de incrementos 1, 2, 3, 4, 6, 8, 9, 12, 16, ..., 2 𝑝 3 𝑞 , o algoritmo ShellSort necessita de 𝑂(𝑛 * 𝑙𝑜𝑔(𝑛)2 ) passos para ordenar um vetor que possui 𝑛 elementos. Na figura 9 é possível observar como o número de movimento de registros em um vetor ordenado é consideravelmente menor que nos demais casos. Já na figura 10, nota-se que o tempo de execução do algoritmo para o vetor de valores aleatórios e muitos superior aos demais casos. Figura 10 – Tempo de execução do algoritmo ShellSort 2.5 SelectionSort Este algoritmo, também chamado de Seleção Simples, possui como ideia básica selecionar um elemento e colocá-lo em sua posição correta. Para tal, seleciona o menor item do vetor e troca-o de lugar com o da primeira posição, repetindo isto para todos os demais elementos 𝑛 − 1 elementos restantes. Em 𝐴𝑙𝑔𝑜𝑟𝑡ℎ𝑚 5, é possível observar um pseudocódigo deste algoritmo. O algoritmo SelectionSort compara, a cada iteração, um elemento com todos os demais não ordenados, visando a encontrar o menor. Desta maneira, na primeira iteração são comparados 𝑛 − 1 elementos, em seguida 𝑛 − 2 e assim por diante(ZIVIANI, 2007). Assim, obtém-se o somatório expresso na Equação 2.6. Logo, pode-se concluir que esta abordagem é dominada assintoticamente por 𝑂(𝑛2 ), não existindo melhora caso o vetor este ordenado ou em ordem inversa.
  12. 12. Algorithm 5 SelectionSort 1: function SelectionSort(V, N) 2: for 𝑖 < − 0 : 𝑁 − 1 do 3: 𝑚𝑒𝑛𝑜𝑟 < − 𝑉 [𝑖] 4: 𝑖𝑛𝑑𝑒𝑥 < − 𝑖 5: for 𝑗 < − 𝑖 + 1 : 𝑁 do 6: if 𝑉 [𝑗] < 𝑚𝑒𝑛𝑜𝑟 then 7: 𝑚𝑒𝑛𝑜𝑟 < − 𝑉 [𝑗] 8: 𝑖𝑛𝑑𝑒𝑥 < − 𝑗 9: end if 10: end for 11: 𝑉 [𝑖𝑛𝑑𝑒𝑥] < − 𝑉 [𝑖] 12: 𝑉 [𝑖] < − 𝑚𝑒𝑛𝑜𝑟 13: end for 14: end function (𝑛 − 1) + (𝑛 − 2) + (𝑛 − 1) + ... + 2 + 1 = 𝑛 * 𝑛 − 1 2 = 𝑛2 2 − 𝑛 2 (2.6) Figura 11 – Número de movimentos de registro do algoritmo SelectionSort Na figura 11, nota-se que independente da orientação do vetor, nas execuções foram realizados o mesmo número de movimentação de registros. Já na figura 12, percebe-se que, apesar da mesma complexidade em todos os casos, o vetor em ordem reversa necessitou de muito mais tempo para realizar a operação de ordenação. 2.6 HeapSort O algoritmo HeapSort utiliza o mesmo princípio do SelectionSort para realizar a ordenação. Contudo, faz uso de uma estrutura de dados específica: o ℎ𝑒𝑎𝑝. Com a utilização dessa estrutura, o custo para recuperar o menor elemento é drasticamente reduzido (ZIVIANI, 2007).
  13. 13. Figura 12 – Tempo de execução do algoritmo SelectionSort Essa abordagem consiste basicamente em construir um ℎ𝑒𝑎𝑝 𝑚á𝑥𝑖𝑚𝑜, trocar o primeiro elemento com o último, diminuir o tamanho do ℎ𝑒𝑎𝑝 e rearranjar o ℎ𝑒𝑎𝑝 𝑚á𝑥𝑖𝑚𝑜. Isso é repetido para os 𝑛 elementos . Mantendo as propriedades de ℎ𝑒𝑎𝑝 𝑚á𝑥𝑖𝑚𝑎, pode-se retirar sucessivamente ele- mentos da raiz do ℎ𝑒𝑎𝑝 na ordem desejada. Vale lembrar, ainda, que isso garante uma ordenação em ordem crescente. Caso sejam mantidas as propriedades de ℎ𝑒𝑎𝑝 𝑚í𝑛𝑖𝑚𝑎, obter-se-á a ordem decrescente. O custo do HeapSort é devido ao procedimento de criar o ℎ𝑒𝑎𝑝 𝑚á𝑥𝑖𝑚𝑜: 𝑂(𝑙𝑜𝑔𝑛). Como o procedimento é repetido para o 𝑛 elementos, tem-se que esta abordagem é domi- nada assintoticamente por 𝑂(𝑛 * 𝑙𝑜𝑔𝑛). Figura 13 – Número de movimentos de registro do algoritmo HeapSort
  14. 14. Na figura 13, nota-se que o número de movimento de registros é próxima para qualquer ordem de vetor, porém alto. Na figura 14, nota-se que nos testes os vetores alea- tórios tiveram um tempo que oscilou em relação aos demais. Ora todos ficaram próximos, ora o aleatório levava um tempo superior, para o mesmo comprimento de vetor. Figura 14 – Tempo de execução do algoritmo HeapSort 2.7 MergeSort O MergeSort utiliza a abordagem dividir e conquistar. Nele um vetor é dividido em duas partes, recursivamente. Em seguida, cada metade é ordenada e ambas são inter- caladas formando o vetor ordenado. O pseudocódigo está em 𝐴𝑙𝑔𝑜𝑟𝑖𝑡ℎ𝑚 6. Na linha 3, é encontrado o meio do vetor; nas linhas 4 e 5 são realizadas as chamadas recursivas com as metades do vetor; na linha 6 ocorre a intercalação. Algorithm 6 MergeSort 1: function MergeSort(V, e, d) 2: if 𝑒 < 𝑑 then 3: 𝑚𝑒𝑖𝑜 < − (𝑒 + 𝑑)/2 4: 𝑀 𝑒𝑟𝑔𝑒𝑆𝑜𝑟𝑡(𝑉, 𝑒, 𝑚𝑒𝑖𝑜) 5: 𝑀 𝑒𝑟𝑔𝑒𝑆𝑜𝑟𝑡(𝑉, 𝑚𝑒𝑖𝑜 + 1, 𝑑) 6: 𝑀 𝑒𝑟𝑔𝑒(𝑉, 𝑒, 𝑞, 𝑑) 7: end if 8: end function Quanto à complexidade deste algortimo, a recursão é dada por 𝑇(𝑛) = 2𝑇( 𝑛 2 ) + 𝑛 (2.7)
  15. 15. Pelo segundo caso: 𝑐1 * 𝑛𝑙𝑜𝑔2 2 ≤ 𝑛 ≤ 𝑐2 * 𝑛𝑙𝑜𝑔2 2 (2.8) Tomando 𝑐1 = 𝑐2 = 1, têm-se uma afirmação verdadeira. Logo, a complexidade do MergeSort é dado por Θ(𝑛 * 𝑙𝑜𝑔𝑛) (2.9) Nos testes realizados, notou-se que independentemente da ordenação do vetor são realizados o mesmo número de movimentações de registros (figura 15). Quanto ao custo necessário para realizar o procedimento, a partir de um 1000 elementos, foi necessário o mesmo tempo para os vetores em ordem crescente e decrescente. Já o vetor em ordem aleatória foi mais custoso a partir deste ponto (figura 16). Figura 15 – Número de movimentos de registro do algoritmo MergeSort Figura 16 – Tempo de execução do algoritmo MergeSort
  16. 16. 3 Discussões Nas figuras 17, 18 e 19 são apresentadas comparações entre todas as técnicas tes- tadas, em ordem crescente, descrescente e alatória, respectivamente. Nota-se facilmente como alguns métodos mudam sua eficiência em relação aos demais dependendo da quan- tidade de elementos e a ordem do vetor. Para vetores em ordem crescente, destacam-se as implementações do algoritmo QuickSort. Neste que é seu pior caso, ele necessita de um tempo muito superior aos demais. Utilizando o elemento central como pivô, o tempo é reduzido quase que pela metade, mesmo assim ficando acima dos outros. Já para os vetores em ordem decrescente, os 𝑂(𝑛2 ) ficam evidentes. As implemen- tações do BubbleSort são as que exigem maior tempo. Destacam-se o ShellSort, MergeSort e HeapSort, que mantêm-se próximos ao eixo x. Finelmente, para entradas aleatórias, as implementações do BubbleSort dispara- ram como as mais custosas. Já o QuickSort utilizando o elemento central como pivô, o InsertionSort e o SelectionSort apresentaram desempenho parecido, destacando-se essa implementação do QuickSort, que a partir de 20000 elementos passa a ser mais custoso que os outros 2. Já o MergeSort, o HeapSort, o ShellSort e o QuickSort utilizando o primeiro elemento como pivô permanecem juntos independe do comprimento do vetor. Figura 17 – Comparação do tempo de execução de todos algoritmos testados: vetor cres- cente
  17. 17. Figura 18 – Comparação do tempo de execução de todos algoritmos testados: vetor de- crescente Figura 19 – Comparação do tempo de execução de todos algoritmos testados: vetor alea- tório Tomando vetores independente de orientação, os melhores foram HeapSort, Shell- Sort, MergeSort. Na figura 20, é possível observar uma comparação entre estes métodos. Nota-se que para vetores ordenados diretamente e inversamente, o ShellSort é melhor, en- quanto os demais são mais lentos, porém próximos entre si. Já para uma entrada alaetória, o HeapSort é o mais rápido, apesar de desempenho semelhante aos demais.
  18. 18. Entre os algoritmos 𝑂(𝑛2 ), o InsertionSort apresentou o melhor desempenho para qualquer tamanho de vetor em ordem aleatória. (a) Vetor crescente (b) Vetor decrescente (c) Vetor aleatório Figura 20 – Comparação entre os métodos HeapSort, ShellSort e MergeSort Cabe ainda um comentário quanto ao QuickSort, que para vetores em ordem ale- atória apresentou desempenho semelhante ao HeapSort. Sua abordagem recursiva exige mais memória que outros algoritmos quando implementado. Assim, para a realização dos testes, fez-se uso de uma estrutura de dados extra. Uma pilha permitiu a execução de uma versão iterativa deste algoritmo.
  19. 19. Referências LANG, H. W. Shellsort. Consultado em 30/04/2015. 2010. Disponível em: <http://www.iti.fh-flensburg.de/lang/algorithmen/sortieren/shell/shellen.htm>. 11 LEISERSON, C. et al. Algoritmos: teoria e prática. [S.l.]: CAMPUS - RJ, 2002. ISBN 9788535209266. 10 ZIVIANI, N. Projeto de algoritmos: com implementações em Java e C++. [S.l.]: Thomson Learning, 2007. ISBN 9788522105250. 11, 12, 13

×